add poll support

Fixes #130
This commit is contained in:
Henry Hiles 2026-11-02 12:03:52 -05:00
commit f6aaf49164
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
27 changed files with 1041 additions and 144 deletions

View file

@ -1,4 +1,3 @@
import "package:collection/collection.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
@ -10,11 +9,14 @@ class EventController(final GetEventRequest request)
@override @override
Future<Event?> build() async { Future<Event?> build() async {
final event = ref.watch( final event = ref.watch(
RoomsController.provider.select( RoomsController.provider.select((rooms) {
(rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull( final room = rooms[request.roomId];
(event) => event.eventId == request.eventId,
), return switch (room?.eventIds[request.eventId]) {
), final rowId? => room?.events[rowId],
null => null,
};
}),
); );
if (event != null) return event; if (event != null) return event;

View file

@ -0,0 +1,132 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/event_id_is_fake.dart";
import "package:nexus/models/configs/event.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/encrypted.dart";
import "package:nexus/models/content/poll_end.dart";
import "package:nexus/models/content/poll_response.dart";
import "package:nexus/models/content/poll_start.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/poll_votes.dart";
class PollVotesController(final EventConfig config)
extends AsyncNotifier<PollVotes> {
@override
Future<PollVotes> build() async {
// Only what's needed, so e.g. reactions don't cause refetches
final (poll, localRelated) = ref.watch(
RoomsController.provider.select((rooms) {
final room = rooms[config.roomId];
final event = room?.events[config.eventRowId];
if (room == null || event == null) {
return (null, const IList<Event>.empty());
}
final edited = event.lastEditRowId == 0
? null
: room.events[event.lastEditRowId];
return (
(
eventId: event.eventId,
sender: event.sender,
// Edits can change the answers
content: (edited ?? event).content,
),
(room.relations[event.eventId] ?? const ISet<int>.empty())
.map((rowId) => room.events[rowId])
.nonNulls
.where((related) => related.relationType == "m.reference")
.toIList(),
);
}),
);
final powerLevels = ref.watch(
RoomPowerLevelsController.provider(config.roomId),
);
if (poll == null) return const PollVotes();
final PollStart(:answers, :selectionLimit) = switch (poll.content) {
PollStartContent(:final poll) => poll,
_ => const PollStart(),
};
final fetched = await ref
.read(ClientController.provider.notifier)
.getRelatedEvents(
.new(
roomId: config.roomId,
eventId: poll.eventId,
relationType: "m.reference",
),
);
final related =
IMap.fromIterable(
(fetched ?? const IList.empty()).addAll(localRelated),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
).values
// gomuks' database has unsent local echoes too
.where((event) => event.redactedBy == null && !event.eventId.isFake)
.toIList();
final end = related
.where(
(event) =>
event.content is PollEndContent &&
powerLevels.canEndPoll(event.sender, pollSender: poll.sender),
)
.sortedBy((event) => event.timestamp)
.firstOrNull;
final endedAt = end?.timestamp;
bool beforeEnd(Event event) =>
endedAt == null || !event.timestamp.isAfter(endedAt);
final answerIds = answers.map((answer) => answer.id).toISet();
return .new(
endEventId: end?.eventId,
undecryptable: related
.where(
(event) => event.content is EncryptedContent && beforeEnd(event),
)
.length,
// Each user's latest response before the end counts, even if invalid
votes: related
.where(
(event) =>
event.type == EventType.pollResponse.type && beforeEnd(event),
)
.toIList()
.sort((a, b) => a.timestamp.compareTo(b.timestamp))
.fold(const .empty(), (votes, event) {
// Truncated before removing duplicates, as MSC3381 specifies
final picked = switch (event.content) {
PollResponseContent(:final answers) =>
answers.take(selectionLimit).toISet(),
_ => null,
};
return votes.add(
event.sender,
// Spoiled responses count as no answers
picked != null && picked.every(answerIds.contains)
? picked.toIList()
: const .empty(),
);
}),
);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<PollVotesController, PollVotes, EventConfig>(
PollVotesController.new,
);
}

View file

@ -2,10 +2,10 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/configs/reactions.dart"; import "package:nexus/models/configs/event.dart";
import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/content/reaction.dart";
class ReactionsController(final ReactionsConfig config) class ReactionsController(final EventConfig config)
extends AsyncNotifier<IMap<String, IList<String>>> { extends AsyncNotifier<IMap<String, IList<String>>> {
@override @override
Future<IMap<String, IList<String>>> build() async { Future<IMap<String, IList<String>>> build() async {
@ -48,6 +48,6 @@ class ReactionsController(final ReactionsConfig config)
AsyncNotifierProvider.family< AsyncNotifierProvider.family<
ReactionsController, ReactionsController,
IMap<String, IList<String>>, IMap<String, IList<String>>,
ReactionsConfig EventConfig
>(ReactionsController.new); >(ReactionsController.new);
} }

View file

@ -8,8 +8,12 @@ import "package:fluttertagger/fluttertagger.dart";
import "package:nexus/controllers/attachment.dart"; import "package:nexus/controllers/attachment.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/send_failed.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/poll_end.dart";
import "package:nexus/models/content/poll_response.dart";
import "package:nexus/models/content/poll_start.dart";
import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/direction.dart"; import "package:nexus/models/direction.dart";
@ -340,10 +344,10 @@ class RoomChatController(final ChatParams info)
); );
} }
/// Sends [image] as a sticker, as a reply to [repliedEventRowId] if set. /// Sends [content], as a reply to [repliedEventRowId] if set.
Future<void> sendSticker( Future<void> sendWithReply(
String shortcode, EventType type,
PackImage image, { Content content, {
bool shouldMention = true, bool shouldMention = true,
int? repliedEventRowId, int? repliedEventRowId,
}) async { }) async {
@ -360,12 +364,8 @@ class RoomChatController(final ChatParams info)
.sendEvent( .sendEvent(
.new( .new(
roomId: info.roomId, roomId: info.roomId,
type: EventType.sticker.type, type: type.type,
content: StickerContent( content: content,
body: image.body ?? shortcode,
info: image.info ?? const .new(),
url: image.url,
),
mentions: Mentions( mentions: Mentions(
userIds: [ userIds: [
if (shouldMention && repliedEvent != null) repliedEvent.sender, if (shouldMention && repliedEvent != null) repliedEvent.sender,
@ -387,6 +387,70 @@ class RoomChatController(final ChatParams info)
); );
} }
/// Sends [image] as a sticker, as a reply to [repliedEventRowId] if set.
Future<void> sendSticker(
String shortcode,
PackImage image, {
bool shouldMention = true,
int? repliedEventRowId,
}) => sendWithReply(
.sticker,
StickerContent(
body: image.body ?? shortcode,
info: image.info ?? const .new(),
url: image.url,
),
shouldMention: shouldMention,
repliedEventRowId: repliedEventRowId,
);
Future<void> sendPoll(
PollStart poll, {
bool shouldMention = true,
int? repliedEventRowId,
}) => sendWithReply(
.pollStart,
PollStartContent(poll: poll),
shouldMention: shouldMention,
repliedEventRowId: repliedEventRowId,
);
/// Completes once the event has come down sync.
Future<void> sendPollReference(
Event poll,
EventType type,
Content content,
) async {
final event = await ref
.read(ClientController.provider.notifier)
.sendEvent(
.new(
roomId: info.roomId,
type: type.type,
content: content,
relatesTo: poll.eventId,
relationType: "m.reference",
synchronous: true,
),
);
if (event.sendFailed) throw Exception(event.sendError);
await ref
.read(RoomsController.provider.notifier)
.awaitEvent(info.roomId, event.eventId);
}
/// An empty [answers] removes the vote.
Future<void> votePoll(Event poll, IList<String> answers) => sendPollReference(
poll,
.pollResponse,
PollResponseContent(answers: answers),
);
Future<void> endPoll(Event poll) =>
sendPollReference(poll, .pollEnd, const PollEndContent());
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, RoomChat?, ChatParams>( .autoDispose<RoomChatController, RoomChat?, ChatParams>(
RoomChatController.new, RoomChatController.new,

View file

@ -82,6 +82,35 @@ class RoomsController extends Notifier<IMap<String, Room>> {
), ),
); );
static IMap<String, ISet<int>> addRelations(
IMap<String, ISet<int>> relations,
Iterable<Event> events,
) => events.fold(
relations,
(acc, event) => switch (event.relatesTo) {
final relatesTo? => acc.update(
relatesTo,
(rowIds) => rowIds.add(event.rowId),
ifAbsent: () => .new([event.rowId]),
),
null => acc,
},
);
/// IDs replace previous ones for the same row, e.g. once a local echo is sent.
static IMap<String, int> addEventIds(
IMap<String, int> eventIds,
IMap<int, Event> existing,
Iterable<Event> events,
) => events.fold(eventIds, (acc, event) {
final previousId = existing[event.rowId]?.eventId;
return (previousId != null && previousId != event.eventId
? acc.remove(previousId)
: acc)
.add(event.eventId, event.rowId);
});
void update( void update(
IMap<String, Room> rooms, [ IMap<String, Room> rooms, [
ISet<String> leftRooms = const .empty(), ISet<String> leftRooms = const .empty(),
@ -109,6 +138,15 @@ class RoomsController extends Notifier<IMap<String, Room>> {
events: incoming.events.isEmpty events: incoming.events.isEmpty
? existing.events ? existing.events
: existing.events.addAll(incoming.events), : existing.events.addAll(incoming.events),
relations: addRelations(
existing.relations,
incoming.events.values,
),
eventIds: addEventIds(
existing.eventIds,
existing.events,
incoming.events.values,
),
state: incoming.state.entries.fold( state: incoming.state.entries.fold(
existing.state, existing.state,
(previousValue, event) => previousValue.add( (previousValue, event) => previousValue.add(
@ -129,6 +167,12 @@ class RoomsController extends Notifier<IMap<String, Room>> {
) ?? ) ??
incoming.copyWith( incoming.copyWith(
receipts: mergeReceipts(const .empty(), incoming.receipts), receipts: mergeReceipts(const .empty(), incoming.receipts),
relations: addRelations(const .empty(), incoming.events.values),
eventIds: addEventIds(
const .empty(),
const .empty(),
incoming.events.values,
),
), ),
); );
}); });
@ -144,12 +188,19 @@ class RoomsController extends Notifier<IMap<String, Room>> {
String type, String type,
String eventId, { String eventId, {
String stateKey = "", String stateKey = "",
}) { }) => awaitRooms(
bool arrived(IMap<String, Room> rooms) => (rooms) => switch (rooms[roomId]?.state[type]?[stateKey]) {
switch (rooms[roomId]?.state[type]?[stateKey]) {
final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId, final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId,
null => false, null => false,
}; },
);
/// Waits for a sent event to come down sync, giving up after a while.
Future<void> awaitEvent(String roomId, String eventId) => awaitRooms(
(rooms) => rooms[roomId]?.eventIds.containsKey(eventId) ?? false,
);
Future<void> awaitRooms(bool Function(IMap<String, Room> rooms) arrived) {
if (arrived(state)) return .value(); if (arrived(state)) return .value();
final completer = Completer<void>(); final completer = Completer<void>();

View file

@ -1,15 +1,19 @@
import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/encrypted.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/poll_start.dart";
import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
extension IsShownAsMessage on Event { extension IsShownAsMessage on Event {
/// Whether this is a message, sticker or poll.
bool get hasMessageContent => switch (content) {
MessageContent() || StickerContent() || PollStartContent() => true,
_ => false,
};
/// Whether this is shown in the timeline as a message, with MessageRenderer. /// Whether this is shown in the timeline as a message, with MessageRenderer.
bool get isShownAsMessage => bool get isShownAsMessage =>
redactedBy == null && redactedBy == null &&
relationType != "m.replace" && relationType != "m.replace" &&
switch (content) { (hasMessageContent || content is EncryptedContent);
MessageContent() || EncryptedContent() || StickerContent() => true,
_ => false,
};
} }

View file

@ -0,0 +1,6 @@
import "package:nexus/models/event.dart";
extension SendFailed on Event {
/// gomuks marks events that are still sending as "not sent" (and often forgets to clear it at all)
bool get sendFailed => sendError != null && sendError != "not sent";
}

View file

@ -0,0 +1,16 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "event.freezed.dart";
part "event.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const EventConfig({
required final String roomId,
required final int eventRowId,
}) with _$EventConfig {
Map<String, Object?> toJson() => _$EventConfigToJson(this);
factory EventConfig.fromJson(Map<String, Object?> json) =>
_$EventConfigFromJson(json);
}

View file

@ -1,16 +0,0 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "reactions.freezed.dart";
part "reactions.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const ReactionsConfig({
required final String roomId,
required final int eventRowId,
}) with _$ReactionsConfig {
Map<String, Object?> toJson() => _$ReactionsConfigToJson(this);
factory ReactionsConfig.fromJson(Map<String, Object?> json) =>
_$ReactionsConfigFromJson(json);
}

View file

@ -17,6 +17,9 @@ import "package:nexus/models/content/topic.dart";
import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/poll_end.dart";
import "package:nexus/models/content/poll_response.dart";
import "package:nexus/models/content/poll_start.dart";
class const Content({final String? parseError, final StackTrace? stackTrace}) { class const Content({final String? parseError, final StackTrace? stackTrace}) {
factory Content.fromJson(Map<String, dynamic> json) => const Content(); factory Content.fromJson(Map<String, dynamic> json) => const Content();
@ -64,5 +67,11 @@ enum EventType(
pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson),
imagePack("m.room.image_pack", ImagePackContent.fromJson), imagePack("m.room.image_pack", ImagePackContent.fromJson),
legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson), legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson),
pollStart("org.matrix.msc3381.poll.start", PollStartContent.fromJson),
pollResponse(
"org.matrix.msc3381.poll.response",
PollResponseContent.fromJson,
),
pollEnd("org.matrix.msc3381.poll.end", PollEndContent.fromJson),
message("m.room.message", MessageContent.fromJson), message("m.room.message", MessageContent.fromJson),
} }

View file

@ -0,0 +1,12 @@
import "package:nexus/models/content/content.dart";
class const PollEndContent() extends Content {
factory PollEndContent.fromJson(Map<String, dynamic> json) =>
const PollEndContent();
@override
Map<String, dynamic> toJson() => {
"org.matrix.msc3381.poll.end": {},
"org.matrix.msc1767.text": "Ended the poll",
};
}

View file

@ -0,0 +1,23 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/content.dart";
part "poll_response.freezed.dart";
part "poll_response.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
class const PollResponseContent({
@JsonKey(readValue: PollResponseContent.answersFromJson)
final IList<String> answers = const .empty(),
}) extends Content with _$PollResponseContent {
static List<dynamic>? answersFromJson(Map<dynamic, dynamic> json, _) =>
json["org.matrix.msc3381.poll.response"]?["answers"];
@override
Map<String, Object?> toJson() => {
"org.matrix.msc3381.poll.response": {"answers": answers.toList()},
};
factory PollResponseContent.fromJson(Map<String, Object?> json) =>
_$PollResponseContentFromJson(json);
}

View file

@ -0,0 +1,90 @@
import "dart:math";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/content.dart";
part "poll_start.freezed.dart";
part "poll_start.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
class const PollStartContent({
@JsonKey(name: "org.matrix.msc3381.poll.start") required final PollStart poll,
}) extends Content with _$PollStartContent {
@override
Map<String, Object?> toJson() => {
"org.matrix.msc3381.poll.start": poll.toJson(),
// Shown by clients without poll support
"org.matrix.msc1767.text": [
poll.question,
...poll.answers.mapIndexed(
(index, answer) => "${index + 1}. ${answer.text}",
),
].join("\n"),
};
factory PollStartContent.fromJson(Map<String, Object?> json) =>
_$PollStartContentFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PollStart({
@JsonKey(readValue: PollStart.questionFromJson) final String question = "",
final int? maxSelections,
@JsonKey(readValue: PollStart.answersFromJson)
final IList<PollAnswer> answers = const .empty(),
}) with _$PollStart {
static const maxAnswers = 20;
static String? extensibleTextFromJson(Object? json) => switch (json) {
{"org.matrix.msc1767.text": final String text} => text,
{"body": final String body} => body,
{"org.matrix.msc1767.message": final List message} =>
message
.whereType<Map>()
.map((part) => part["body"])
.whereType<String>()
.firstOrNull,
_ => null,
};
static String? questionFromJson(Map<dynamic, dynamic> json, String key) =>
PollStart.extensibleTextFromJson(json[key]);
static List<dynamic>? answersFromJson(
Map<dynamic, dynamic> json,
String key,
) => (json[key] as List?)?.take(maxAnswers).toList();
/// [maxSelections], defaulting to 1, and at most the number of answers
int get selectionLimit =>
(maxSelections ?? 1).clamp(1, max(answers.length, 1));
Map<String, Object?> toJson() => {
..._$PollStartToJson(this),
// We don't allow undisclosed polls as they make no sense
"kind": "org.matrix.msc3381.poll.disclosed",
"question": {"org.matrix.msc1767.text": question},
"answers": answers.map((answer) => answer.toJson()).toList(),
};
factory PollStart.fromJson(Map<String, Object?> json) =>
_$PollStartFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
class const PollAnswer({
required final String id,
@JsonKey(readValue: PollAnswer.textFromJson) final String text = "",
}) with _$PollAnswer {
static String? textFromJson(Map<dynamic, dynamic> json, _) =>
PollStart.extensibleTextFromJson(json);
Map<String, Object?> toJson() => {"id": id, "org.matrix.msc1767.text": text};
factory PollAnswer.fromJson(Map<String, Object?> json) =>
_$PollAnswerFromJson(json);
}

View file

@ -0,0 +1,25 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
class const PollVotes({
/// Each user's answers, by user ID
final IMap<String, IList<String>> votes = const .empty(),
final String? endEventId,
final int undecryptable = 0,
}) {
bool get hasEnded => endEventId != null;
IMap<String, IList<String>> get votersByAnswer => votes.entries.fold(
const .empty(),
(acc, entry) => entry.value.fold(
acc,
(acc, answer) => acc.update(
answer,
(voters) => voters.add(entry.key),
ifAbsent: () => .new([entry.key]),
),
),
);
int get voterCount =>
votes.values.where((answers) => answers.isNotEmpty).length;
}

View file

@ -24,6 +24,14 @@ class const Room({
@JsonKey(fromJson: Room.eventsJsonToIMap) @JsonKey(fromJson: Room.eventsJsonToIMap)
final IMap<int, Event> events = const .empty(), final IMap<int, Event> events = const .empty(),
/// [relations] is an IMap of event ID to the eventRowIds relating to it
@JsonKey(includeFromJson: false, includeToJson: false)
final IMap<String, ISet<int>> relations = const .empty(),
/// [eventIds] is an IMap of event ID to eventRowId
@JsonKey(includeFromJson: false, includeToJson: false)
final IMap<String, int> eventIds = const .empty(),
final bool reset = false, final bool reset = false,
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
final bool resetClientSticky = false, final bool resetClientSticky = false,

View file

@ -8,4 +8,7 @@ class const RoomPowerLevels({
double levelOf(String userId) => creators.contains(userId) double levelOf(String userId) => creators.contains(userId)
? double.infinity ? double.infinity
: (content.users[userId] ?? content.usersDefault).toDouble(); : (content.users[userId] ?? content.usersDefault).toDouble();
bool canEndPoll(String userId, {required String pollSender}) =>
userId == pollSender || levelOf(userId) >= content.redact;
} }

View file

@ -20,9 +20,11 @@ import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/image_packs.dart"; import "package:nexus/controllers/image_packs.dart";
import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/poll_start.dart";
import "package:nexus/models/image_pack.dart"; import "package:nexus/models/image_pack.dart";
import "package:nexus/widgets/mxc_emoji.dart"; import "package:nexus/widgets/mxc_emoji.dart";
import "package:nexus/widgets/sticker_picker.dart"; import "package:nexus/widgets/sticker_picker.dart";
import "package:nexus/widgets/create_poll_sheet.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart"; import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
import "package:nexus/widgets/composer/paste_files_action.dart"; import "package:nexus/widgets/composer/paste_files_action.dart";
@ -30,6 +32,7 @@ import "package:pasteboard/pasteboard.dart";
import "package:path/path.dart"; import "package:path/path.dart";
import "package:path_provider/path_provider.dart"; import "package:path_provider/path_provider.dart";
import "package:nexus/widgets/composer/relation_preview.dart"; import "package:nexus/widgets/composer/relation_preview.dart";
import "package:nexus/widgets/composer/composer_replacement.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
class const Composer( class const Composer(
@ -49,6 +52,11 @@ class const Composer(
required bool shouldMention, required bool shouldMention,
}) })
onSendSticker, onSendSticker,
required final Future<void> Function(
PollStart poll, {
required bool shouldMention,
})
onSendPoll,
final FocusNode? node, final FocusNode? node,
super.key, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@ -70,6 +78,11 @@ class const Composer(
.value .value
?.any((pack) => pack.stickers.isNotEmpty) ?? ?.any((pack) => pack.stickers.isNotEmpty) ??
false); false);
final canSendPolls = ref.watch(
PowerLevelController.provider(
.new(eventType: .pollStart, roomId: roomId),
),
);
final relatedEvent = this.relatedEvent == null final relatedEvent = this.relatedEvent == null
? null ? null
: ref.watch( : ref.watch(
@ -95,15 +108,24 @@ class const Composer(
/// The sticker to send, which replaces the text field until it is sent or /// The sticker to send, which replaces the text field until it is sent or
/// removed, as stickers can't have a caption /// removed, as stickers can't have a caption
final sticker = useState<(String, PackImage)?>(null); final sticker = useState<(String, PackImage)?>(null);
final poll = useState<PollStart?>(null);
useEffect(() { useEffect(() {
sticker.value = null; sticker.value = null;
poll.value = null;
return null; return null;
}, [roomId]); }, [roomId]);
final hasReplacement = sticker.value != null || poll.value != null;
// The relation type stays as edit after cancelling an edit
final isEditing = relationType == .edit && relatedEvent != null;
useEffect(() { useEffect(() {
if (relationType == .edit && relatedEvent != null) { if (relationType == .edit && relatedEvent != null) {
textBefore.value ??= controller.text; textBefore.value ??= controller.text;
sticker.value = null; sticker.value = null;
poll.value = null;
final text = final text =
relatedEvent.localContent?.editSource ?? relatedEvent.localContent?.editSource ??
@ -216,11 +238,16 @@ class const Composer(
} }
void send() { void send() {
if (hasReplacement) {
if (sticker.value case (final shortcode, final image)?) { if (sticker.value case (final shortcode, final image)?) {
onSendSticker(shortcode, image, shouldMention: shouldMention.value); onSendSticker(shortcode, image, shouldMention: shouldMention.value);
} else if (poll.value case final draft?) {
onSendPoll(draft, shouldMention: shouldMention.value);
}
onDismiss(); onDismiss();
// The draft is kept, so it can still be sent after the sticker // The draft is kept, so it can still be sent after the replacement
sticker.value = null; sticker.value = null;
poll.value = null;
node?.requestFocus(); node?.requestFocus();
return; return;
} }
@ -302,7 +329,7 @@ class const Composer(
) )
? [ ? [
IconButton( IconButton(
onPressed: sticker.value != null onPressed: hasReplacement
? null ? null
: () => context.showAdaptiveSheet( : () => context.showAdaptiveSheet(
(context) => EmojiPicker( (context) => EmojiPicker(
@ -338,8 +365,7 @@ class const Composer(
), ),
AdaptiveMenuButton( AdaptiveMenuButton(
tooltip: "Add media", tooltip: "Add media",
enabled: enabled: attachment == null && !hasReplacement,
attachment == null && sticker.value == null,
itemBuilder: (context) => [ itemBuilder: (context) => [
for (final (source, title, icon) in [ for (final (source, title, icon) in [
if (Platform.isAndroid || Platform.isIOS) if (Platform.isAndroid || Platform.isIOS)
@ -386,7 +412,7 @@ class const Composer(
if (canSendStickers) if (canSendStickers)
PopupMenuItem( PopupMenuItem(
// Stickers can't replace messages // Stickers can't replace messages
enabled: relationType != .edit, enabled: !isEditing,
onTap: () => context.showAdaptiveSheet( onTap: () => context.showAdaptiveSheet(
(context) => StickerPicker( (context) => StickerPicker(
roomId: roomId, roomId: roomId,
@ -401,6 +427,22 @@ class const Composer(
leading: Icon(Icons.sticky_note_2), leading: Icon(Icons.sticky_note_2),
), ),
), ),
if (canSendPolls)
PopupMenuItem(
enabled: !isEditing,
onTap: () => context.showAdaptiveSheet(
(context) => CreatePollSheet(
onCreate: (created) {
Navigator.of(context).pop();
poll.value = created;
},
),
),
child: ListTile(
title: Text("Poll"),
leading: Icon(Icons.poll),
),
),
], ],
icon: Icon(Icons.add), icon: Icon(Icons.add),
), ),
@ -409,35 +451,49 @@ class const Composer(
child: AnimatedSize( child: AnimatedSize(
duration: Durations.short4, duration: Durations.short4,
alignment: .bottomCenter, alignment: .bottomCenter,
child: switch (sticker.value) { child: switch ((sticker.value, poll.value)) {
(final shortcode, final image)? => Focus( (_, final draft?) => ComposerReplacement(
autofocus: true, removeTooltip: "Remove poll",
onRemove: () {
poll.value = null;
node?.requestFocus();
},
onKeyEvent: sendOnEnter, onKeyEvent: sendOnEnter,
child: Padding( ListTile(
padding: .symmetric(vertical: 8), leading: Icon(
child: Row( Icons.poll,
children: [ color: theme.colorScheme.primary,
ConstrainedBox( ),
title: Text(draft.question),
subtitle: Text(
draft.answers
.map((answer) => answer.text)
.join(" · "),
maxLines: 1,
overflow: .ellipsis,
),
),
),
((final shortcode, final image)?, _) =>
ComposerReplacement(
removeTooltip: "Remove sticker",
onRemove: () {
sticker.value = null;
node?.requestFocus();
},
onKeyEvent: sendOnEnter,
Align(
alignment: .centerLeft,
child: ConstrainedBox(
constraints: .loose(.square(128)), constraints: .loose(.square(128)),
child: MxcEmoji( child: MxcEmoji(
image.url, image.url,
label: image.body ?? shortcode, label: image.body ?? shortcode,
), ),
), ),
Spacer(),
IconButton(
tooltip: "Remove sticker",
onPressed: () {
sticker.value = null;
node?.requestFocus();
},
icon: Icon(Icons.close),
),
],
), ),
), ),
), (null, null) => FlutterTagger(
null => FlutterTagger(
triggerStrategy: .eager, triggerStrategy: .eager,
// Characters that continue a search, for // Characters that continue a search, for
// e.g. :+1, :custom_emoji or @user:server // e.g. :+1, :custom_emoji or @user:server

View file

@ -0,0 +1,30 @@
import "package:material_ui/material_ui.dart";
/// Replaces the composer's text field with something to send, like a sticker.
class const ComposerReplacement(
final Widget child, {
required final String removeTooltip,
required final VoidCallback onRemove,
final FocusOnKeyEventCallback? onKeyEvent,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Focus(
autofocus: true,
onKeyEvent: onKeyEvent,
child: Padding(
padding: .symmetric(vertical: 8),
child: Row(
spacing: 12,
children: [
Expanded(child: child),
IconButton(
tooltip: removeTooltip,
onPressed: onRemove,
icon: Icon(Icons.close),
),
],
),
),
);
}

View file

@ -0,0 +1,109 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/content/poll_start.dart";
import "package:nexus/widgets/sheet_layout.dart";
class const CreatePollSheet({
required final void Function(PollStart poll) onCreate,
super.key,
}) extends HookWidget {
@override
Widget build(BuildContext context) {
final questionController = useTextEditingController();
final question = useValueListenable(questionController).text.trim();
// Keyed by ID, so fields keep their text when one above is removed
final nextId = useRef(2);
final answers = useState<IMap<int, String>>(.new({0: "", 1: ""}));
final filledAnswers = answers.value.entries
.map(
(answer) =>
PollAnswer(id: "${answer.key}", text: answer.value.trim()),
)
.where((answer) => answer.text.isNotEmpty)
.toIList();
final allowMultiple = useState(false);
final canCreate = question.isNotEmpty && filledAnswers.length >= 2;
void submit() => onCreate(
.new(
question: question,
answers: filledAnswers,
maxSelections: allowMultiple.value ? filledAnswers.length : 1,
),
);
return SheetLayout(
title: const Text("Create a Poll"),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text("Cancel"),
),
TextButton(
onPressed: canCreate ? submit : null,
child: const Text("Create"),
),
],
children: [
TextField(
controller: questionController,
autofocus: true,
textCapitalization: .sentences,
decoration: const .new(labelText: "Question"),
),
AnimatedSize(
duration: Durations.medium2,
curve: Curves.easeInOut,
alignment: .topCenter,
child: Column(
spacing: 12,
children: answers.value.entries
.mapIndexed(
(index, answer) => TextFormField(
key: ValueKey(answer.key),
initialValue: answer.value,
textCapitalization: .sentences,
onChanged: (text) =>
answers.value = answers.value.add(answer.key, text),
decoration: .new(
labelText: "Answer ${index + 1}",
suffixIcon: answers.value.length > 2
? IconButton(
tooltip: "Remove answer",
icon: const Icon(Icons.close),
onPressed: () => answers.value = answers.value
.remove(answer.key),
)
: null,
),
),
)
.toList(),
),
),
Align(
alignment: .centerLeft,
child: TextButton.icon(
onPressed: answers.value.length < PollStart.maxAnswers
? () => answers.value = answers.value.add(nextId.value++, "")
: null,
icon: const Icon(Icons.add),
label: const Text("Add Answer"),
),
),
SwitchListTile(
secondary: const Icon(Icons.checklist),
title: const Text("Allow multiple answers"),
subtitle: const Text("Voters can choose more than one"),
value: allowMultiple.value,
onChanged: (value) => allowMultiple.value = value,
),
],
);
}
}

View file

@ -1,6 +1,5 @@
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/helpers/extensions/is_shown_as_message.dart";
import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/widgets/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart";
@ -10,10 +9,7 @@ class const EventPreview(final Event event, {super.key})
extends StatelessWidget { extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showSender = switch (event.content) { final showSender = event.hasMessageContent;
MessageContent() || StickerContent() => true,
_ => false,
};
return IgnorePointer( return IgnorePointer(
child: Padding( child: Padding(

Some files were not shown because too many files have changed in this diff Show more