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
9 changed files with 1041 additions and 144 deletions

View file

@ -20,9 +20,11 @@ import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/image_packs.dart";
import "package:nexus/models/content/image_pack.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/widgets/mxc_emoji.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/widgets/composer/overlays/tagger_overlay.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_provider/path_provider.dart";
import "package:nexus/widgets/composer/relation_preview.dart";
import "package:nexus/widgets/composer/composer_replacement.dart";
import "package:nexus/main.dart";
class const Composer(
@ -49,6 +52,11 @@ class const Composer(
required bool shouldMention,
})
onSendSticker,
required final Future<void> Function(
PollStart poll, {
required bool shouldMention,
})
onSendPoll,
final FocusNode? node,
super.key,
}) extends HookConsumerWidget {
@ -70,6 +78,11 @@ class const Composer(
.value
?.any((pack) => pack.stickers.isNotEmpty) ??
false);
final canSendPolls = ref.watch(
PowerLevelController.provider(
.new(eventType: .pollStart, roomId: roomId),
),
);
final relatedEvent = this.relatedEvent == null
? null
: ref.watch(
@ -95,15 +108,24 @@ class const Composer(
/// The sticker to send, which replaces the text field until it is sent or
/// removed, as stickers can't have a caption
final sticker = useState<(String, PackImage)?>(null);
final poll = useState<PollStart?>(null);
useEffect(() {
sticker.value = null;
poll.value = null;
return null;
}, [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(() {
if (relationType == .edit && relatedEvent != null) {
textBefore.value ??= controller.text;
sticker.value = null;
poll.value = null;
final text =
relatedEvent.localContent?.editSource ??
@ -216,11 +238,16 @@ class const Composer(
}
void send() {
if (sticker.value case (final shortcode, final image)?) {
onSendSticker(shortcode, image, shouldMention: shouldMention.value);
if (hasReplacement) {
if (sticker.value case (final shortcode, final image)?) {
onSendSticker(shortcode, image, shouldMention: shouldMention.value);
} else if (poll.value case final draft?) {
onSendPoll(draft, shouldMention: shouldMention.value);
}
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;
poll.value = null;
node?.requestFocus();
return;
}
@ -302,7 +329,7 @@ class const Composer(
)
? [
IconButton(
onPressed: sticker.value != null
onPressed: hasReplacement
? null
: () => context.showAdaptiveSheet(
(context) => EmojiPicker(
@ -338,8 +365,7 @@ class const Composer(
),
AdaptiveMenuButton(
tooltip: "Add media",
enabled:
attachment == null && sticker.value == null,
enabled: attachment == null && !hasReplacement,
itemBuilder: (context) => [
for (final (source, title, icon) in [
if (Platform.isAndroid || Platform.isIOS)
@ -386,7 +412,7 @@ class const Composer(
if (canSendStickers)
PopupMenuItem(
// Stickers can't replace messages
enabled: relationType != .edit,
enabled: !isEditing,
onTap: () => context.showAdaptiveSheet(
(context) => StickerPicker(
roomId: roomId,
@ -401,6 +427,22 @@ class const Composer(
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),
),
@ -409,35 +451,49 @@ class const Composer(
child: AnimatedSize(
duration: Durations.short4,
alignment: .bottomCenter,
child: switch (sticker.value) {
(final shortcode, final image)? => Focus(
autofocus: true,
child: switch ((sticker.value, poll.value)) {
(_, final draft?) => ComposerReplacement(
removeTooltip: "Remove poll",
onRemove: () {
poll.value = null;
node?.requestFocus();
},
onKeyEvent: sendOnEnter,
child: Padding(
padding: .symmetric(vertical: 8),
child: Row(
children: [
ConstrainedBox(
constraints: .loose(.square(128)),
child: MxcEmoji(
image.url,
label: image.body ?? shortcode,
),
),
Spacer(),
IconButton(
tooltip: "Remove sticker",
onPressed: () {
sticker.value = null;
node?.requestFocus();
},
icon: Icon(Icons.close),
),
],
ListTile(
leading: Icon(
Icons.poll,
color: theme.colorScheme.primary,
),
title: Text(draft.question),
subtitle: Text(
draft.answers
.map((answer) => answer.text)
.join(" · "),
maxLines: 1,
overflow: .ellipsis,
),
),
),
null => FlutterTagger(
((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)),
child: MxcEmoji(
image.url,
label: image.body ?? shortcode,
),
),
),
),
(null, null) => FlutterTagger(
triggerStrategy: .eager,
// Characters that continue a search, for
// 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:nexus/models/content/message.dart";
import "package:nexus/models/content/sticker.dart";
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
@ -10,10 +9,7 @@ class const EventPreview(final Event event, {super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final showSender = switch (event.content) {
MessageContent() || StickerContent() => true,
_ => false,
};
final showSender = event.hasMessageContent;
return IgnorePointer(
child: Padding(

View file

@ -0,0 +1,67 @@
import "package:material_ui/material_ui.dart";
class const PollOption(
final String text, {
required final bool picked,
required final bool isSingleChoice,
/// From 0 to 1
required final double share,
final bool isWinner = false,
final bool loading = false,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final emphasis = isWinner ? const TextStyle(fontWeight: .bold) : null;
return Column(
crossAxisAlignment: .stretch,
spacing: 10,
children: [
Row(
spacing: 12,
children: [
SizedBox.square(
dimension: 20,
child: loading
? const CircularProgressIndicator(strokeWidth: 2.5)
: Icon(
switch ((isSingleChoice, picked)) {
(true, true) => Icons.radio_button_checked,
(true, false) => Icons.radio_button_unchecked,
(false, true) => Icons.check_box,
(false, false) => Icons.check_box_outline_blank,
},
size: 20,
color: picked
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
Expanded(
child: Text(
text,
style: theme.textTheme.bodyLarge?.merge(emphasis),
),
),
Text(
"${(share * 100).round()}%",
style: theme.textTheme.labelLarge
?.copyWith(color: colorScheme.onSurfaceVariant)
.merge(emphasis),
),
],
),
TweenAnimationBuilder(
tween: Tween(end: share),
duration: Durations.medium2,
curve: Curves.easeInOut,
builder: (context, value, _) => LinearProgressIndicator(value: value),
),
],
);
}
}

View file

@ -4,10 +4,12 @@ import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/poll_votes.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
import "package:nexus/helpers/extensions/send_failed.dart";
import "package:nexus/helpers/extensions/show_adaptive_menu.dart";
import "package:nexus/helpers/extensions/success_scheme.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
@ -22,6 +24,7 @@ import "package:nexus/models/content/join_rules.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/name.dart";
import "package:nexus/models/content/pinned_events.dart";
import "package:nexus/models/content/poll_end.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/content/server_acl.dart";
import "package:nexus/models/content/topic.dart";
@ -181,6 +184,29 @@ class const EventRenderer(
"changed the room avatar",
),
CreateContent() => generic(Icons.add, "created the room"),
PollEndContent() => switch (event.relatesTo) {
final pollId? => switch (ref
.watch(
EventController.provider(
.new(roomId: roomId, eventId: pollId),
),
)
.value) {
final poll?
when ref
.watch(
PollVotesController.provider(
.new(roomId: roomId, eventRowId: poll.rowId),
),
)
.value
?.endEventId ==
event.eventId =>
generic(Icons.poll, "ended a poll"),
_ => null,
},
null => null,
},
final PowerLevelsContent content => generic(
Icons.power,
"changed the room's power levels",
@ -427,7 +453,7 @@ class const EventRenderer(
if (readReceiptsChat case final chat? when event.isShownAsMessage)
ReadReceiptsRow(event.eventId, chat: chat),
if (event.sendError != null && event.sendError != "not sent")
if (event.sendFailed)
Padding(
padding: .only(bottom: 4),
child: Row(

View file

@ -8,6 +8,7 @@ import "package:nexus/helpers/extensions/get_filename.dart";
import "package:nexus/helpers/extensions/linkify_html.dart";
import "package:nexus/models/content/encrypted.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/event.dart";
import "package:nexus/widgets/file_card.dart";
@ -23,6 +24,7 @@ import "package:nexus/widgets/url_preview.dart";
import "package:nexus/widgets/event_preview.dart";
import "package:nexus/widgets/players/video.dart";
import "package:nexus/widgets/players/audio.dart";
import "package:nexus/widgets/renderers/poll.dart";
class const MessageRenderer(
final Event event, {
@ -54,6 +56,14 @@ class const MessageRenderer(
constraints: .loose(.square(200)),
child: MessageImage(url, info: info, encrypted: false),
),
PollStartContent(:final poll) =>
textOnly
? Text(
"Poll: ${poll.question}",
maxLines: maxLines,
overflow: .ellipsis,
)
: PollRenderer(event, poll),
// TODO: Handle locations
// LocationMessageContent(:final body , :final geoUri) =>
MessageContent content => switch (content) {

View file

@ -0,0 +1,220 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:m3e_buttons/m3e_buttons.dart";
import "package:m3e_card_list/m3e_card_list.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/poll_votes.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/helpers/extensions/event_id_is_fake.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/main.dart";
import "package:nexus/models/content/poll_start.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/poll_option.dart";
import "package:nexus/widgets/sheet_layout.dart";
class const PollRenderer(final Event event, final PollStart poll, {super.key})
extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
/// The answer being voted for, or no answer while ending the poll
final pending = useState<({String? answerId})?>(null);
final isEnding = switch (pending.value) {
(answerId: null) => true,
_ => false,
};
final votesProvider = PollVotesController.provider(
.new(roomId: event.roomId, eventRowId: event.rowId),
);
final votesState = ref.watch(votesProvider);
final votes = votesState.value ?? const .new();
final userId = ref.watch(
ClientStateController.provider.select((value) => value?.userId),
);
final powerLevels = ref.watch(
RoomPowerLevelsController.provider(event.roomId),
);
final isOpen = !event.eventId.isFake && !votes.hasEnded;
final canVote =
isOpen &&
votesState.hasValue &&
ref.watch(
PowerLevelController.provider(
.new(eventType: .pollResponse, roomId: event.roomId),
),
);
final canEnd =
isOpen &&
userId != null &&
powerLevels.canEndPoll(userId, pollSender: event.sender);
final ownVote =
(userId == null ? null : votes.votes[userId]) ?? const .empty();
final votersByAnswer = votes.votersByAnswer;
final mostVotes =
votersByAnswer.values.map((voters) => voters.length).maxOrNull ?? 0;
final isSingleChoice = poll.selectionLimit == 1;
final notifier = RoomChatController.provider((
roomId: event.roomId,
contextualEvent: null,
)).notifier;
Future<void> run(
({String? answerId}) action,
Future<void> Function() send,
) async {
pending.value = action;
await send().onError(showError);
// Otherwise the old votes show until they're refetched
await ref.read(votesProvider.future).onError((_, _) => const .new());
if (context.mounted) pending.value = null;
}
Future<void> toggle(String answerId) {
final picked = ownVote.contains(answerId)
? ownVote.remove(answerId)
: isSingleChoice
? IList([answerId])
: ownVote.add(answerId);
return run(
(answerId: answerId),
() => ref
.read(notifier)
.votePoll(
event,
// Past the limit, the oldest pick is replaced
picked.length > poll.selectionLimit ? picked.removeAt(0) : picked,
),
);
}
void confirmEnd() => context.showAdaptiveSheet(
(sheetContext) => SheetLayout(
title: const Text("End Poll"),
actions: [
TextButton(
onPressed: Navigator.of(sheetContext).pop,
child: const Text("Cancel"),
),
TextButton(
onPressed: () {
Navigator.of(sheetContext).pop();
run((answerId: null), () => ref.read(notifier).endPoll(event));
},
child: const Text("End"),
),
],
children: const [
Text(
"Are you sure you want to end this poll? "
"No one will be able to vote after it ends.",
),
],
),
);
final voterCount = votes.voterCount;
return ConstrainedBox(
constraints: const .new(maxWidth: 400),
child: Column(
crossAxisAlignment: .stretch,
mainAxisSize: .min,
spacing: 12,
children: [
Column(
crossAxisAlignment: .start,
spacing: 2,
children: [
Text(
poll.question.isEmpty ? "Untitled poll" : poll.question,
style: theme.textTheme.titleMedium,
),
Text(
[
votes.hasEnded
? "Ended"
: isSingleChoice
? "Choose one"
: "Choose up to ${poll.selectionLimit}",
"$voterCount ${voterCount == 1 ? "vote" : "votes"}",
].join(" · "),
style: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
if (votesState case AsyncError(:final error))
Text(
"Failed to load votes: $error",
style: .new(color: colorScheme.error),
),
if (votes.undecryptable case final count when count > 0)
Row(
spacing: 8,
children: [
Icon(Icons.lock, size: 16, color: colorScheme.error),
Flexible(
child: Text(
"$count ${count == 1 ? "response" : "responses"} couldn't "
"be decrypted, so the results may be incomplete",
style: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.error,
),
),
),
],
),
M3ECardColumn(
color: colorScheme.surface,
outerRadius: 16,
padding: const .symmetric(horizontal: 16, vertical: 12),
onTap: canVote && pending.value == null
? (index) => toggle(poll.answers[index].id)
: null,
children: poll.answers.map((answer) {
final count = votersByAnswer[answer.id]?.length ?? 0;
return PollOption(
answer.text,
picked: ownVote.contains(answer.id),
isSingleChoice: isSingleChoice,
share: voterCount == 0 ? 0 : count / voterCount,
isWinner: votes.hasEnded && count > 0 && count == mostVotes,
loading: pending.value?.answerId == answer.id,
);
}).toList(),
),
if (canEnd)
Align(
alignment: .centerRight,
child: M3EButton.icon(
style: .tonal,
size: .xs,
onPressed: pending.value == null ? confirmEnd : null,
icon: isEnding
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.stop_circle),
label: const Text("End Poll"),
),
),
],
),
);
}
}

View file

@ -38,6 +38,9 @@ final class const RoomChat({
Widget build(BuildContext context, WidgetRef ref) {
final relatedEvent = useState<int?>(null);
final relationType = useState(RelationType.reply);
final repliedEventRowId = relationType.value == .reply
? relatedEvent.value
: null;
final pendingJump = ref.read(JumpRequestController.provider);
final contextualEvent = useState<int?>(
pendingJump != null && pendingJump.roomId == this.roomId
@ -334,11 +337,19 @@ final class const RoomChat({
image,
shouldMention: shouldMention,
repliedEventRowId:
relationType.value == .reply
? relatedEvent.value
: null,
repliedEventRowId,
)
.onError(showError),
onSendPoll:
(poll, {required shouldMention}) =>
notifier
.sendPoll(
poll,
shouldMention: shouldMention,
repliedEventRowId:
repliedEventRowId,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () {