From 0fce3689f22e7c00e7e3f7c9cbb3c88cf3bbda06 Mon Sep 17 00:00:00 2001 From: Airyzz <36567925+Airyzz@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:38:41 +1030 Subject: [PATCH 1/7] initial rendering of poll events --- .../client/components/component_registry.dart | 2 + .../components/polls/poll_component.dart | 21 +++ .../polls/matrix_poll_component.dart | 59 ++++++++ .../events/timeline_event_view_poll.dart | 136 ++++++++++++++++++ .../timeline_events/timeline_view_entry.dart | 36 ++++- 5 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 commet/lib/client/components/polls/poll_component.dart create mode 100644 commet/lib/client/matrix/components/polls/matrix_poll_component.dart create mode 100644 commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart diff --git a/commet/lib/client/components/component_registry.dart b/commet/lib/client/components/component_registry.dart index 7a830c04c..a52d6f90f 100644 --- a/commet/lib/client/components/component_registry.dart +++ b/commet/lib/client/components/component_registry.dart @@ -18,6 +18,7 @@ import 'package:commet/client/matrix/components/key_verification_component/matri import 'package:commet/client/matrix/components/photo_album_room/matrix_photo_album_room_component.dart'; import 'package:commet/client/matrix/components/pinned_messages/matrix_pinned_messages_component.dart'; import 'package:commet/client/matrix/components/message_effects/matrix_message_effects_component.dart'; +import 'package:commet/client/matrix/components/polls/matrix_poll_component.dart'; import 'package:commet/client/matrix/components/profile/matrix_profile_component.dart'; import 'package:commet/client/matrix/components/push_notifications/matrix_push_notification_component.dart'; import 'package:commet/client/matrix/components/space_banner/matrix_space_banner_component.dart'; @@ -62,6 +63,7 @@ class ComponentRegistry { MatrixUserColorComponent(client), MatrixDonationAwardsComponent(client), MatrixKeyVerificationComponent(client), + MatrixPollComponent(client), ]; } diff --git a/commet/lib/client/components/polls/poll_component.dart b/commet/lib/client/components/polls/poll_component.dart new file mode 100644 index 000000000..e49c650cc --- /dev/null +++ b/commet/lib/client/components/polls/poll_component.dart @@ -0,0 +1,21 @@ +import 'package:commet/client/client.dart'; +import 'package:commet/client/components/component.dart'; +import 'package:commet/client/timeline_events/timeline_event.dart'; + +class PollAnswer { + String id; + String answer; + + PollAnswer(this.id, this.answer); +} + +abstract class PollComponent implements Component { + Map> getPollResponses( + Timeline timeline, TimelineEvent event); + + bool isPollEvent(TimelineEvent event); + + String getPollQuestion(TimelineEvent event); + + List getAllowedPollAnswers(TimelineEvent event); +} diff --git a/commet/lib/client/matrix/components/polls/matrix_poll_component.dart b/commet/lib/client/matrix/components/polls/matrix_poll_component.dart new file mode 100644 index 000000000..fd1bd6a58 --- /dev/null +++ b/commet/lib/client/matrix/components/polls/matrix_poll_component.dart @@ -0,0 +1,59 @@ +import 'package:commet/client/client.dart'; +import 'package:commet/client/components/polls/poll_component.dart'; +import 'package:commet/client/matrix/matrix_client.dart'; +import 'package:commet/client/matrix/matrix_timeline.dart'; +import 'package:commet/client/matrix/timeline_events/matrix_timeline_event.dart'; +import 'package:commet/client/timeline.dart'; +import 'package:commet/client/timeline_events/timeline_event.dart'; +import 'package:matrix/msc_extensions/msc_3381_polls/poll_event_extension.dart'; + +class MatrixPollComponent implements PollComponent { + @override + MatrixClient client; + + MatrixPollComponent(this.client); + + bool isPollEvent(TimelineEvent event) { + var e = event as MatrixTimelineEvent; + + return e.event.type == "org.matrix.msc3381.poll.start"; + } + + @override + List getAllowedPollAnswers(TimelineEvent event) { + var mxEvent = (event as MatrixTimelineEvent).event; + + return mxEvent.parsedPollEventContent.pollStartContent.answers + .map((i) => PollAnswer(i.id, i.mText)) + .toList(); + } + + @override + Map> getPollResponses( + Timeline timeline, TimelineEvent event) { + Map> responses = {}; + + var mxEvent = (event as MatrixTimelineEvent).event; + + var mxResponses = + mxEvent.getPollResponses((timeline as MatrixTimeline).matrixTimeline!); + + for (var userId in mxResponses.keys) { + for (var answer in mxResponses[userId]!) { + if (responses.containsKey(answer) == false) { + responses[answer] = Set(); + } + responses[answer]!.add(userId); + } + } + + return responses; + } + + @override + String getPollQuestion(TimelineEvent event) { + var mxEvent = (event as MatrixTimelineEvent).event; + + return mxEvent.parsedPollEventContent.pollStartContent.question.mText; + } +} diff --git a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart new file mode 100644 index 000000000..607ca6581 --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart @@ -0,0 +1,136 @@ +import 'package:commet/client/components/polls/poll_component.dart'; +import 'package:commet/client/timeline.dart'; +import 'package:commet/ui/molecules/timeline_events/layouts/timeline_event_layout_message.dart'; +import 'package:commet/ui/molecules/timeline_events/timeline_event_layout.dart'; +import 'package:flutter/material.dart'; + +import 'package:tiamat/tiamat.dart' as tiamat; + +class TimelineEventViewPoll extends StatefulWidget { + const TimelineEventViewPoll( + {required this.initialIndex, required this.timeline, super.key}); + + final int initialIndex; + final Timeline timeline; + @override + State createState() => _TimelineEventViewPollState(); +} + +class _TimelineEventViewPollState extends State + implements TimelineEventViewWidget { + PollComponent? polls; + String? body; + + ImageProvider? senderAvatar; + late String senderName; + late String senderId; + late Color senderColor; + List allowedAnswers = []; + Map> pollResponses = {}; + + @override + void initState() { + polls = widget.timeline.client.getComponent(); + setStateFromIndex(widget.initialIndex); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return TimelineEventLayoutMessage( + senderName: senderName, + senderColor: senderColor, + senderAvatar: senderAvatar, + showSender: true, + formattedContent: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + if (body != null) tiamat.Text.label(body!), + for (var answer in allowedAnswers) buildAnswer(answer), + ], + ), + ); + } + + @override + void update(int newIndex) { + setStateFromIndex(newIndex); + } + + void setStateFromIndex(int index) { + setState(() { + final event = widget.timeline.events[index]; + + var sender = widget.timeline.room.getMemberOrFallback(event.senderId); + + senderId = sender.identifier; + senderName = sender.displayName; + senderAvatar = sender.avatar; + senderColor = sender.defaultColor; + + body = polls!.getPollQuestion(event); + allowedAnswers = polls!.getAllowedPollAnswers(event); + pollResponses = polls!.getPollResponses(widget.timeline, event); + }); + } + + Widget buildAnswer(PollAnswer answer) { + var responses = pollResponses[answer.id]; + + var isOurResponse = + responses?.contains(widget.timeline.client.self!.identifier) == true; + + int mostVoted = 1; + + if (responses != null) { + for (var answer in allowedAnswers) { + var r = pollResponses[answer.id]; + if (r != null) { + var len = r.length; + if (len > mostVoted) { + mostVoted = len; + } + } + } + } + + var bodyColor = isOurResponse ? ColorScheme.of(context).onPrimary : null; + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: BoxBorder.all( + color: ColorScheme.of(context).outlineVariant.withAlpha(200), + width: 1), + color: isOurResponse + ? ColorScheme.of(context).primaryContainer + : ColorScheme.of(context).surfaceContainerLow), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + spacing: 4, + children: [ + Row( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + tiamat.Text( + answer.answer, + color: bodyColor, + ), + tiamat.Text( + (responses?.length ?? 0).toString(), + color: bodyColor, + ) + ], + ), + LinearProgressIndicator( + value: (responses?.length ?? 0) / mostVoted, color: bodyColor) + ], + ), + ), + ); + } +} diff --git a/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart b/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart index 5d85026f2..a87130f8b 100644 --- a/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart +++ b/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart @@ -1,4 +1,5 @@ import 'package:commet/client/client.dart'; +import 'package:commet/client/components/polls/poll_component.dart'; import 'package:commet/client/components/read_receipts/read_receipt_component.dart'; import 'package:commet/client/components/threads/thread_component.dart'; import 'package:commet/client/timeline_events/timeline_event.dart'; @@ -15,6 +16,7 @@ import 'package:commet/ui/atoms/adaptive_context_menu.dart'; import 'package:commet/ui/atoms/emoji_widget.dart'; import 'package:commet/ui/molecules/timeline_events/events/timeline_event_view_generic.dart'; import 'package:commet/ui/molecules/timeline_events/events/timeline_event_view_message.dart'; +import 'package:commet/ui/molecules/timeline_events/events/timeline_event_view_poll.dart'; import 'package:commet/ui/molecules/timeline_events/timeline_event_date_time_marker.dart'; import 'package:commet/ui/molecules/timeline_events/timeline_event_layout.dart'; import 'package:commet/ui/molecules/timeline_events/timeline_event_menu.dart'; @@ -65,6 +67,7 @@ class TimelineViewEntry extends StatefulWidget { enum TimelineEventWidgetDisplayType { message, generic, + poll, hidden, } @@ -91,12 +94,14 @@ class TimelineViewEntryState extends State bool showDateSeperator = false; ThreadsComponent? threads; + PollComponent? polls; List readReceipts = []; @override void initState() { threads = widget.timeline.room.client.getComponent(); + polls = widget.timeline.client.getComponent(); isThreadReply = threads?.isEventInResponseToThread( widget.timeline.events[widget.initialIndex], widget.timeline) ?? @@ -122,25 +127,33 @@ class TimelineViewEntryState extends State index = eventIndex; time = event.originServerTs; - _widgetType = eventToDisplayType(event); + _widgetType = eventToDisplayType(event, polls: polls); showDateSeperator = shouldEventShowDate(eventIndex); highlighted = event.eventId == widget.highlightedEventId; } - static TimelineEventWidgetDisplayType eventToDisplayType( - TimelineEvent event) { + static TimelineEventWidgetDisplayType eventToDisplayType(TimelineEvent event, + {PollComponent? polls}) { if (event is TimelineEventMessage || event is TimelineEventSticker || event is TimelineEventEncrypted) { return TimelineEventWidgetDisplayType.message; - } else if (event is TimelineEventGeneric) { + } + + if (event is TimelineEventGeneric) { return TimelineEventWidgetDisplayType.generic; - } else if (event.status == TimelineEventStatus.error) { + } + + if (polls?.isPollEvent(event) == true) { + return TimelineEventWidgetDisplayType.poll; + } + + if (event.status == TimelineEventStatus.error) { return TimelineEventWidgetDisplayType.generic; - } else { - return TimelineEventWidgetDisplayType.hidden; } + + return TimelineEventWidgetDisplayType.hidden; } bool shouldEventShowDate(int index) { @@ -406,6 +419,7 @@ class TimelineViewEntryState extends State jumpToEvent: widget.jumpToEvent, previewMedia: widget.previewMedia, initialIndex: widget.initialIndex); + if (_widgetType == TimelineEventWidgetDisplayType.generic) return TimelineEventViewGeneric( timeline: widget.timeline, @@ -416,6 +430,14 @@ class TimelineViewEntryState extends State key: eventKey, ); + if (_widgetType == TimelineEventWidgetDisplayType.poll) { + return TimelineEventViewPoll( + initialIndex: widget.initialIndex, + timeline: widget.timeline, + key: eventKey, + ); + } + if (preferences.developerMode.value == false && _widgetType == TimelineEventWidgetDisplayType.hidden) { return null; From 38bfeb3abb620dd9067a777361c38a23ef604b11 Mon Sep 17 00:00:00 2001 From: Airyzz <36567925+Airyzz@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:21:30 +1030 Subject: [PATCH 2/7] implement answering polls --- .../components/polls/poll_component.dart | 9 ++ .../polls/matrix_poll_component.dart | 37 +++++ .../events/timeline_event_view_poll.dart | 149 +++++++++++++----- 3 files changed, 158 insertions(+), 37 deletions(-) diff --git a/commet/lib/client/components/polls/poll_component.dart b/commet/lib/client/components/polls/poll_component.dart index e49c650cc..161b58c04 100644 --- a/commet/lib/client/components/polls/poll_component.dart +++ b/commet/lib/client/components/polls/poll_component.dart @@ -17,5 +17,14 @@ abstract class PollComponent implements Component { String getPollQuestion(TimelineEvent event); + int getMaxSelections(TimelineEvent event); + + bool shouldShowResults(TimelineEvent event, Timeline timeline); + + bool canVote(TimelineEvent event, Timeline timeline); + List getAllowedPollAnswers(TimelineEvent event); + + Future setAnswer( + TimelineEvent event, Room room, List answer); } diff --git a/commet/lib/client/matrix/components/polls/matrix_poll_component.dart b/commet/lib/client/matrix/components/polls/matrix_poll_component.dart index fd1bd6a58..5697c04bc 100644 --- a/commet/lib/client/matrix/components/polls/matrix_poll_component.dart +++ b/commet/lib/client/matrix/components/polls/matrix_poll_component.dart @@ -1,10 +1,12 @@ import 'package:commet/client/client.dart'; import 'package:commet/client/components/polls/poll_component.dart'; import 'package:commet/client/matrix/matrix_client.dart'; +import 'package:commet/client/matrix/matrix_room.dart'; import 'package:commet/client/matrix/matrix_timeline.dart'; import 'package:commet/client/matrix/timeline_events/matrix_timeline_event.dart'; import 'package:commet/client/timeline.dart'; import 'package:commet/client/timeline_events/timeline_event.dart'; +import 'package:matrix/matrix.dart' as matrix; import 'package:matrix/msc_extensions/msc_3381_polls/poll_event_extension.dart'; class MatrixPollComponent implements PollComponent { @@ -28,6 +30,13 @@ class MatrixPollComponent implements PollComponent { .toList(); } + @override + int getMaxSelections(TimelineEvent event) { + var mxEvent = (event as MatrixTimelineEvent).event; + + return mxEvent.parsedPollEventContent.pollStartContent.maxSelections; + } + @override Map> getPollResponses( Timeline timeline, TimelineEvent event) { @@ -56,4 +65,32 @@ class MatrixPollComponent implements PollComponent { return mxEvent.parsedPollEventContent.pollStartContent.question.mText; } + + @override + Future setAnswer( + TimelineEvent event, Room room, List answers) async { + var mxEvent = (event as MatrixTimelineEvent).event; + await mxEvent.answerPoll(answers.map((i) => i.id).toList()); + } + + @override + bool shouldShowResults(TimelineEvent event, Timeline timeline) { + var mxEvent = (event as MatrixTimelineEvent).event; + + if (mxEvent.parsedPollEventContent.pollStartContent.kind == + matrix.PollKind.disclosed) { + return true; + } + + return mxEvent + .getPollHasBeenEnded((timeline as MatrixTimeline).matrixTimeline!); + } + + @override + bool canVote(TimelineEvent event, Timeline timeline) { + var mxEvent = (event as MatrixTimelineEvent).event; + + return !mxEvent + .getPollHasBeenEnded((timeline as MatrixTimeline).matrixTimeline!); + } } diff --git a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart index 607ca6581..b9a7548bf 100644 --- a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart @@ -1,7 +1,11 @@ import 'package:commet/client/components/polls/poll_component.dart'; import 'package:commet/client/timeline.dart'; +import 'package:commet/client/timeline_events/timeline_event.dart'; import 'package:commet/ui/molecules/timeline_events/layouts/timeline_event_layout_message.dart'; import 'package:commet/ui/molecules/timeline_events/timeline_event_layout.dart'; +import 'package:commet/ui/molecules/user_panel.dart'; +import 'package:commet/ui/navigation/adaptive_dialog.dart'; +import 'package:commet/utils/error_utils.dart'; import 'package:flutter/material.dart'; import 'package:tiamat/tiamat.dart' as tiamat; @@ -25,8 +29,12 @@ class _TimelineEventViewPollState extends State late String senderName; late String senderId; late Color senderColor; + int maxSelections = 0; + bool showResults = false; + bool canVote = false; List allowedAnswers = []; Map> pollResponses = {}; + TimelineEvent? event; @override void initState() { @@ -49,6 +57,9 @@ class _TimelineEventViewPollState extends State children: [ if (body != null) tiamat.Text.label(body!), for (var answer in allowedAnswers) buildAnswer(answer), + if (!showResults) + tiamat.Text.labelLow( + "Results will be visible once the poll has ended") ], ), ); @@ -61,18 +72,20 @@ class _TimelineEventViewPollState extends State void setStateFromIndex(int index) { setState(() { - final event = widget.timeline.events[index]; - - var sender = widget.timeline.room.getMemberOrFallback(event.senderId); + final e = widget.timeline.events[index]; + var sender = widget.timeline.room.getMemberOrFallback(e.senderId); senderId = sender.identifier; senderName = sender.displayName; senderAvatar = sender.avatar; senderColor = sender.defaultColor; - - body = polls!.getPollQuestion(event); - allowedAnswers = polls!.getAllowedPollAnswers(event); - pollResponses = polls!.getPollResponses(widget.timeline, event); + maxSelections = polls!.getMaxSelections(e); + showResults = polls!.shouldShowResults(e, widget.timeline); + body = polls!.getPollQuestion(e); + canVote = polls!.canVote(e, widget.timeline); + allowedAnswers = polls!.getAllowedPollAnswers(e); + pollResponses = polls!.getPollResponses(widget.timeline, e); + event = e; }); } @@ -98,37 +111,99 @@ class _TimelineEventViewPollState extends State var bodyColor = isOurResponse ? ColorScheme.of(context).onPrimary : null; - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: BoxBorder.all( - color: ColorScheme.of(context).outlineVariant.withAlpha(200), - width: 1), - color: isOurResponse - ? ColorScheme.of(context).primaryContainer - : ColorScheme.of(context).surfaceContainerLow), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - spacing: 4, - children: [ - Row( - spacing: 8, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - tiamat.Text( - answer.answer, - color: bodyColor, - ), - tiamat.Text( - (responses?.length ?? 0).toString(), - color: bodyColor, - ) - ], + return Material( + clipBehavior: Clip.antiAlias, + color: isOurResponse + ? ColorScheme.of(context).primaryContainer + : ColorScheme.of(context).surfaceContainerLow, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onLongPress: () { + AdaptiveDialog.show( + context, + scrollable: false, + builder: (context) => SizedBox( + height: 400, + width: 400, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: tiamat.Text.largeTitle(answer.answer), + ), + Expanded( + child: ListView.builder( + itemCount: responses?.length ?? 0, + itemBuilder: (context, index) { + final id = responses!.elementAt(index); + return UserPanel( + userId: id, + contextRoom: widget.timeline.room, + client: widget.timeline.client); + }, + ), + ), + ], + ), ), - LinearProgressIndicator( - value: (responses?.length ?? 0) / mostVoted, color: bodyColor) - ], + ); + }, + onTap: !canVote + ? null + : () { + List selectedAnswer; + + if (maxSelections > 1) { + selectedAnswer = allowedAnswers + .where((i) => + pollResponses[i.id]?.contains( + widget.timeline.client.self!.identifier) == + true) + .toList(growable: true); + + if (isOurResponse) { + selectedAnswer.remove(answer); + } else { + selectedAnswer.add(answer); + } + } else { + selectedAnswer = [answer]; + } + + ErrorUtils.tryRun(context, () async { + await polls?.setAnswer( + event!, + widget.timeline.room, + selectedAnswer, + ); + }); + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + spacing: 4, + children: [ + Row( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + tiamat.Text( + answer.answer, + color: bodyColor, + ), + if (showResults) + tiamat.Text( + (responses?.length ?? 0).toString(), + color: bodyColor, + ) + ], + ), + if (showResults) + LinearProgressIndicator( + value: (responses?.length ?? 0) / mostVoted, + color: bodyColor), + ], + ), ), ), ); From 92619f1ba148d0c8295dafbbecbfea5e9c111098 Mon Sep 17 00:00:00 2001 From: Airyzz <36567925+Airyzz@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:38:15 +1030 Subject: [PATCH 3/7] ui improvements --- .../components/polls/poll_component.dart | 2 +- .../polls/matrix_poll_component.dart | 6 +- .../events/timeline_event_view_poll.dart | 100 ++++++++++-------- 3 files changed, 60 insertions(+), 48 deletions(-) diff --git a/commet/lib/client/components/polls/poll_component.dart b/commet/lib/client/components/polls/poll_component.dart index 161b58c04..cb67ffa32 100644 --- a/commet/lib/client/components/polls/poll_component.dart +++ b/commet/lib/client/components/polls/poll_component.dart @@ -21,7 +21,7 @@ abstract class PollComponent implements Component { bool shouldShowResults(TimelineEvent event, Timeline timeline); - bool canVote(TimelineEvent event, Timeline timeline); + bool isFinished(TimelineEvent event, Timeline timeline); List getAllowedPollAnswers(TimelineEvent event); diff --git a/commet/lib/client/matrix/components/polls/matrix_poll_component.dart b/commet/lib/client/matrix/components/polls/matrix_poll_component.dart index 5697c04bc..ae8cc85ff 100644 --- a/commet/lib/client/matrix/components/polls/matrix_poll_component.dart +++ b/commet/lib/client/matrix/components/polls/matrix_poll_component.dart @@ -1,10 +1,8 @@ import 'package:commet/client/client.dart'; import 'package:commet/client/components/polls/poll_component.dart'; import 'package:commet/client/matrix/matrix_client.dart'; -import 'package:commet/client/matrix/matrix_room.dart'; import 'package:commet/client/matrix/matrix_timeline.dart'; import 'package:commet/client/matrix/timeline_events/matrix_timeline_event.dart'; -import 'package:commet/client/timeline.dart'; import 'package:commet/client/timeline_events/timeline_event.dart'; import 'package:matrix/matrix.dart' as matrix; import 'package:matrix/msc_extensions/msc_3381_polls/poll_event_extension.dart'; @@ -87,10 +85,10 @@ class MatrixPollComponent implements PollComponent { } @override - bool canVote(TimelineEvent event, Timeline timeline) { + bool isFinished(TimelineEvent event, Timeline timeline) { var mxEvent = (event as MatrixTimelineEvent).event; - return !mxEvent + return mxEvent .getPollHasBeenEnded((timeline as MatrixTimeline).matrixTimeline!); } } diff --git a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart index b9a7548bf..9dd3947ed 100644 --- a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart @@ -31,7 +31,7 @@ class _TimelineEventViewPollState extends State late Color senderColor; int maxSelections = 0; bool showResults = false; - bool canVote = false; + bool isFinished = false; List allowedAnswers = []; Map> pollResponses = {}; TimelineEvent? event; @@ -50,17 +50,29 @@ class _TimelineEventViewPollState extends State senderColor: senderColor, senderAvatar: senderAvatar, showSender: true, - formattedContent: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - if (body != null) tiamat.Text.label(body!), - for (var answer in allowedAnswers) buildAnswer(answer), - if (!showResults) - tiamat.Text.labelLow( - "Results will be visible once the poll has ended") - ], + formattedContent: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 500), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + if (body != null) tiamat.Text.label(body!), + for (var answer in allowedAnswers) buildAnswer(answer), + Align( + alignment: AlignmentGeometry.topRight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!showResults) + tiamat.Text.labelLow( + "Results will be visible once the poll has ended"), + if (isFinished) tiamat.Text.labelLow("This poll has ended") + ], + ), + ) + ], + ), ), ); } @@ -82,7 +94,7 @@ class _TimelineEventViewPollState extends State maxSelections = polls!.getMaxSelections(e); showResults = polls!.shouldShowResults(e, widget.timeline); body = polls!.getPollQuestion(e); - canVote = polls!.canVote(e, widget.timeline); + isFinished = polls!.isFinished(e, widget.timeline); allowedAnswers = polls!.getAllowedPollAnswers(e); pollResponses = polls!.getPollResponses(widget.timeline, e); event = e; @@ -114,41 +126,43 @@ class _TimelineEventViewPollState extends State return Material( clipBehavior: Clip.antiAlias, color: isOurResponse - ? ColorScheme.of(context).primaryContainer + ? ColorScheme.of(context).primary : ColorScheme.of(context).surfaceContainerLow, borderRadius: BorderRadius.circular(8), child: InkWell( - onLongPress: () { - AdaptiveDialog.show( - context, - scrollable: false, - builder: (context) => SizedBox( - height: 400, - width: 400, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: tiamat.Text.largeTitle(answer.answer), - ), - Expanded( - child: ListView.builder( - itemCount: responses?.length ?? 0, - itemBuilder: (context, index) { - final id = responses!.elementAt(index); - return UserPanel( - userId: id, - contextRoom: widget.timeline.room, - client: widget.timeline.client); - }, + onLongPress: !showResults + ? null + : () { + AdaptiveDialog.show( + context, + scrollable: false, + builder: (context) => SizedBox( + height: 400, + width: 400, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: tiamat.Text.largeTitle(answer.answer), + ), + Expanded( + child: ListView.builder( + itemCount: responses?.length ?? 0, + itemBuilder: (context, index) { + final id = responses!.elementAt(index); + return UserPanel( + userId: id, + contextRoom: widget.timeline.room, + client: widget.timeline.client); + }, + ), + ), + ], ), ), - ], - ), - ), - ); - }, - onTap: !canVote + ); + }, + onTap: isFinished ? null : () { List selectedAnswer; From 3da64a439d7854adccb2a344e9f4553cf828ccb6 Mon Sep 17 00:00:00 2001 From: Airyzz <36567925+Airyzz@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:02:43 +1030 Subject: [PATCH 4/7] support creating polls --- .../components/polls/poll_component.dart | 20 ++ .../polls/matrix_poll_component.dart | 31 +++ commet/lib/ui/molecules/message_input.dart | 66 ++++-- commet/lib/ui/molecules/poll_creator.dart | 191 ++++++++++++++++++ commet/lib/ui/molecules/read_indicator.dart | 13 +- .../events/timeline_event_view_poll.dart | 131 +++++++----- .../timeline_events/timeline_event_menu.dart | 25 +++ tiamat/lib/atoms/circle_button.dart | 8 +- 8 files changed, 407 insertions(+), 78 deletions(-) create mode 100644 commet/lib/ui/molecules/poll_creator.dart diff --git a/commet/lib/client/components/polls/poll_component.dart b/commet/lib/client/components/polls/poll_component.dart index cb67ffa32..78580cf61 100644 --- a/commet/lib/client/components/polls/poll_component.dart +++ b/commet/lib/client/components/polls/poll_component.dart @@ -9,6 +9,20 @@ class PollAnswer { PollAnswer(this.id, this.answer); } +class PollCreateArgs { + String question; + List options; + bool multiAnswer; + bool publicAnswers; + + PollCreateArgs({ + required this.question, + required this.options, + this.multiAnswer = false, + this.publicAnswers = false, + }); +} + abstract class PollComponent implements Component { Map> getPollResponses( Timeline timeline, TimelineEvent event); @@ -25,6 +39,12 @@ abstract class PollComponent implements Component { List getAllowedPollAnswers(TimelineEvent event); + Future createPoll(Room room, PollCreateArgs args); + + Future endPoll(Room room, TimelineEvent event); + + bool canEndPoll(Room room, TimelineEvent event, Timeline timeline); + Future setAnswer( TimelineEvent event, Room room, List answer); } diff --git a/commet/lib/client/matrix/components/polls/matrix_poll_component.dart b/commet/lib/client/matrix/components/polls/matrix_poll_component.dart index ae8cc85ff..6bb43b26d 100644 --- a/commet/lib/client/matrix/components/polls/matrix_poll_component.dart +++ b/commet/lib/client/matrix/components/polls/matrix_poll_component.dart @@ -1,9 +1,11 @@ import 'package:commet/client/client.dart'; import 'package:commet/client/components/polls/poll_component.dart'; import 'package:commet/client/matrix/matrix_client.dart'; +import 'package:commet/client/matrix/matrix_room.dart'; import 'package:commet/client/matrix/matrix_timeline.dart'; import 'package:commet/client/matrix/timeline_events/matrix_timeline_event.dart'; import 'package:commet/client/timeline_events/timeline_event.dart'; +import 'package:commet/utils/rng.dart'; import 'package:matrix/matrix.dart' as matrix; import 'package:matrix/msc_extensions/msc_3381_polls/poll_event_extension.dart'; @@ -91,4 +93,33 @@ class MatrixPollComponent implements PollComponent { return mxEvent .getPollHasBeenEnded((timeline as MatrixTimeline).matrixTimeline!); } + + @override + Future createPoll(Room room, PollCreateArgs args) async { + await (room as MatrixRoom).matrixRoom.startPoll( + question: args.question, + answers: args.options + .map((i) => matrix.PollAnswer( + id: RandomUtils.getRandomString(10), mText: i)) + .toList(), + kind: args.publicAnswers + ? matrix.PollKind.disclosed + : matrix.PollKind.undisclosed, + maxSelections: args.multiAnswer ? args.options.length : 1); + } + + @override + bool canEndPoll(Room room, TimelineEvent event, Timeline timeline) { + if (isFinished(event, timeline)) { + return false; + } + + return event.senderId == room.client.self!.identifier; + } + + @override + Future endPoll(Room room, TimelineEvent event) async { + var mxEvent = (event as MatrixTimelineEvent).event; + await mxEvent.endPoll(); + } } diff --git a/commet/lib/ui/molecules/message_input.dart b/commet/lib/ui/molecules/message_input.dart index 29a3a6811..210c03429 100644 --- a/commet/lib/ui/molecules/message_input.dart +++ b/commet/lib/ui/molecules/message_input.dart @@ -4,16 +4,19 @@ import 'package:commet/client/client.dart'; import 'package:commet/client/components/emoticon/dynamic_emoticon_pack.dart'; import 'package:commet/client/components/emoticon_recent/recent_emoticon_component.dart'; import 'package:commet/client/components/gif/gif_component.dart'; +import 'package:commet/client/components/polls/poll_component.dart'; import 'package:commet/config/build_config.dart'; import 'package:commet/config/layout_config.dart'; import 'package:commet/config/platform_utils.dart'; import 'package:commet/main.dart'; +import 'package:commet/ui/atoms/adaptive_context_menu.dart'; import 'package:commet/ui/atoms/emoji_widget.dart'; import 'package:commet/ui/atoms/keyboard_adaptor.dart'; import 'package:commet/ui/atoms/random_emoji_button.dart'; import 'package:commet/ui/atoms/rich_text_field.dart'; import 'package:commet/ui/molecules/attachment_icon.dart'; import 'package:commet/ui/molecules/overlapping_panels.dart'; +import 'package:commet/ui/molecules/poll_creator.dart'; import 'package:commet/ui/organisms/attachment_processor/attachment_processor.dart'; import 'package:commet/ui/molecules/emoticon_picker.dart'; import 'package:commet/ui/navigation/adaptive_dialog.dart'; @@ -319,6 +322,8 @@ class MessageInputState extends State { ?.call(controller.text.trim(), overrideClient: senderOverride); } + void showMoreAttachmentOptions() {} + // This duration is to try and hide the transition from keyboard popup animation Debouncer removeHeightOverrideDebouncer = Debouncer(delay: Duration(seconds: 1)); @@ -886,7 +891,10 @@ class MessageInputState extends State { } Widget sendMessageButton() { - bool canSend = controller.text.isNotEmpty; + bool canSend = + controller.text.isNotEmpty || widget.attachments?.isNotEmpty == true; + + var pollComponent = widget.room?.client.getComponent(); double targetValue = canSend ? 1 : 0; return Padding( @@ -895,20 +903,48 @@ class MessageInputState extends State { tween: Tween(begin: 0, end: targetValue), duration: Durations.medium1, builder: (context, value, child) { - return SizedBox( - width: widget.size, - height: widget.size, - child: tiamat.CircleButton( - icon: Icons.send, - radius: widget.size * widget.iconScale, - onPressed: sendMessage, - color: Color.lerp( - Theme.of(context).colorScheme.primary.withAlpha(0), - Theme.of(context).colorScheme.primary, - value), - iconColor: Color.lerp(Theme.of(context).colorScheme.secondary, - Theme.of(context).colorScheme.onPrimary, value), - )); + return ClipRRect( + borderRadius: BorderRadiusGeometry.circular(widget.size), + child: Material( + child: AdaptiveContextMenu( + modal: true, + items: [ + if (pollComponent != null) + tiamat.ContextMenuItem( + text: "Poll", + icon: Icons.poll, + onPressed: () async { + var createArgs = + await AdaptiveDialog.show(context, + title: "Create Poll", + builder: (context) => PollCreator()); + + if (createArgs != null) { + print(createArgs); + pollComponent.createPoll(widget.room!, createArgs); + } + }, + ) + ], + child: SizedBox( + width: widget.size, + height: widget.size, + child: tiamat.CircleButton( + icon: canSend ? Icons.send : Icons.more_horiz, + radius: widget.size * widget.iconScale, + onPressed: canSend ? sendMessage : null, + color: Color.lerp( + Theme.of(context).colorScheme.primary.withAlpha(0), + Theme.of(context).colorScheme.primary, + value), + iconColor: Color.lerp( + Theme.of(context).colorScheme.secondary, + Theme.of(context).colorScheme.onPrimary, + value), + )), + ), + ), + ); }, )); } diff --git a/commet/lib/ui/molecules/poll_creator.dart b/commet/lib/ui/molecules/poll_creator.dart new file mode 100644 index 000000000..02dda90a5 --- /dev/null +++ b/commet/lib/ui/molecules/poll_creator.dart @@ -0,0 +1,191 @@ +import 'package:commet/client/components/polls/poll_component.dart'; +import 'package:commet/ui/navigation/adaptive_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:tiamat/tiamat.dart' as tiamat; + +class PollCreator extends StatefulWidget { + const PollCreator({super.key}); + + @override + State createState() => _PollCreatorState(); +} + +class _PollCreatorState extends State { + TextEditingController questionController = TextEditingController(); + bool openPoll = true; + bool multiAnswer = false; + + List options = List.from([ + TextEditingController(), + TextEditingController(), + ], growable: true); + + String? errorMessage; + + @override + Widget build(BuildContext context) { + return SizedBox( + child: SizedBox( + width: 500, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: questionController, + decoration: InputDecoration( + labelText: "Question", + border: const OutlineInputBorder(), + ), + ), + SizedBox( + height: 8, + ), + tiamat.DropdownSelector( + value: openPoll, + items: [true, false], + onItemSelected: (item) { + if (item != null) { + setState(() { + openPoll = item; + }); + } + }, + itemBuilder: (item) { + var msg = item ? "Open Poll" : "Closed Poll"; + var descriptor = item + ? "Voters can see results as they come in" + : "Votes are hidden until the poll ends"; + return Padding( + padding: const EdgeInsets.fromLTRB(0, 6, 0, 0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + tiamat.Text(msg), + tiamat.Text.labelLow(descriptor) + ], + ), + ); + }, + ), + SizedBox( + height: 12, + ), + SizedBox( + height: 200, + child: ScrollConfiguration( + behavior: + ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(2, 8, 2, 8), + child: Column( + spacing: 8, + children: [ + for (int i = 0; i < options.length; i++) + Row( + spacing: 8, + children: [ + Expanded( + child: TextField( + controller: options[i], + decoration: InputDecoration( + suffixIcon: SizedBox( + height: 34, + width: 34, + child: options.length > 2 + ? tiamat.IconButton( + icon: Icons.remove, + onPressed: () { + setState(() { + options.removeAt(i); + }); + }, + ) + : null), + border: const OutlineInputBorder(), + labelText: "Option ${i + 1}"), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: CheckboxListTile( + dense: true, + title: tiamat.Text("Allow multiple answers"), + value: multiAnswer, + controlAffinity: ListTileControlAffinity.leading, + onChanged: (value) { + if (value != null) { + setState(() { + multiAnswer = value; + }); + } + }), + ), + TextButton.icon( + label: Text("Add Option"), + onPressed: () { + setState(() { + options.add(TextEditingController()); + }); + }, + ), + ], + ), + if (errorMessage != null) tiamat.Text.error(errorMessage!), + SizedBox( + height: 12, + ), + tiamat.Button( + text: "Create", + onTap: () async { + setState(() { + errorMessage = null; + }); + + String question = questionController.text; + if (question.isEmpty) { + setState(() { + errorMessage = "Poll must have a question"; + }); + return; + } + + List parsedOptions = List.empty(growable: true); + + for (var controller in options) { + if (controller.text.trim().isEmpty) { + setState(() { + errorMessage = "Poll cannot have a blank option"; + }); + return; + } + + parsedOptions.add(controller.text.trim()); + } + + Navigator.of(context).pop(PollCreateArgs( + question: question, + options: parsedOptions, + multiAnswer: multiAnswer, + publicAnswers: openPoll)); + }) + ], + ), + ), + ); + } +} diff --git a/commet/lib/ui/molecules/read_indicator.dart b/commet/lib/ui/molecules/read_indicator.dart index c30d443a9..1962785f5 100644 --- a/commet/lib/ui/molecules/read_indicator.dart +++ b/commet/lib/ui/molecules/read_indicator.dart @@ -5,10 +5,15 @@ import '../../client/room.dart'; class ReadIndicator extends StatelessWidget { const ReadIndicator( - {required this.room, required this.users, this.onTap, super.key}); + {required this.room, + required this.users, + this.spacing = 6, + this.onTap, + super.key}); final Room room; final Function()? onTap; - final List users; + final Iterable users; + final double spacing; static const int maxItems = 4; @@ -36,11 +41,11 @@ class ReadIndicator extends StatelessWidget { } Widget buildEntry(int index, BuildContext context, {bool fade = false}) { - var member = room.getMemberOrFallback(users[index]); + var member = room.getMemberOrFallback(users.elementAt(index)); return Opacity( opacity: fade ? index / (maxItems - 1) : 1.0, child: Padding( - padding: EdgeInsets.fromLTRB(index.toDouble() * 6, 0, 0, 0), + padding: EdgeInsets.fromLTRB(index.toDouble() * spacing, 0, 0, 0), child: tiamat.Avatar( border: BoxBorder.all( color: ColorScheme.of(context).surfaceContainerLow, diff --git a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart index 9dd3947ed..fbad8a8e8 100644 --- a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart @@ -1,6 +1,7 @@ import 'package:commet/client/components/polls/poll_component.dart'; import 'package:commet/client/timeline.dart'; import 'package:commet/client/timeline_events/timeline_event.dart'; +import 'package:commet/ui/molecules/read_indicator.dart'; import 'package:commet/ui/molecules/timeline_events/layouts/timeline_event_layout_message.dart'; import 'package:commet/ui/molecules/timeline_events/timeline_event_layout.dart'; import 'package:commet/ui/molecules/user_panel.dart'; @@ -45,6 +46,16 @@ class _TimelineEventViewPollState extends State @override Widget build(BuildContext context) { + int totalVotes = 0; + + for (var answer in allowedAnswers) { + var r = pollResponses[answer.id]; + if (r != null) { + var len = r.length; + totalVotes += len; + } + } + return TimelineEventLayoutMessage( senderName: senderName, senderColor: senderColor, @@ -58,18 +69,22 @@ class _TimelineEventViewPollState extends State spacing: 4, children: [ if (body != null) tiamat.Text.label(body!), - for (var answer in allowedAnswers) buildAnswer(answer), - Align( - alignment: AlignmentGeometry.topRight, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (!showResults) - tiamat.Text.labelLow( - "Results will be visible once the poll has ended"), - if (isFinished) tiamat.Text.labelLow("This poll has ended") - ], - ), + for (var answer in allowedAnswers) + buildAnswer(answer, totalVotes: totalVotes), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + tiamat.Text.labelLow("$totalVotes votes"), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!showResults) + tiamat.Text.labelLow( + "Results will be visible once the poll has ended"), + if (isFinished) tiamat.Text.labelLow("This poll has ended") + ], + ), + ], ) ], ), @@ -101,33 +116,15 @@ class _TimelineEventViewPollState extends State }); } - Widget buildAnswer(PollAnswer answer) { + Widget buildAnswer(PollAnswer answer, {int totalVotes = 1}) { var responses = pollResponses[answer.id]; var isOurResponse = responses?.contains(widget.timeline.client.self!.identifier) == true; - int mostVoted = 1; - - if (responses != null) { - for (var answer in allowedAnswers) { - var r = pollResponses[answer.id]; - if (r != null) { - var len = r.length; - if (len > mostVoted) { - mostVoted = len; - } - } - } - } - - var bodyColor = isOurResponse ? ColorScheme.of(context).onPrimary : null; - return Material( clipBehavior: Clip.antiAlias, - color: isOurResponse - ? ColorScheme.of(context).primary - : ColorScheme.of(context).surfaceContainerLow, + color: ColorScheme.of(context).surfaceContainerLow, borderRadius: BorderRadius.circular(8), child: InkWell( onLongPress: !showResults @@ -192,31 +189,53 @@ class _TimelineEventViewPollState extends State ); }); }, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - spacing: 4, - children: [ - Row( - spacing: 8, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - tiamat.Text( - answer.answer, - color: bodyColor, - ), - if (showResults) + child: Container( + decoration: BoxDecoration( + border: BoxBorder.all( + color: isOurResponse + ? ColorScheme.of(context).onSurface.withAlpha(150) + : Colors.transparent, + strokeAlign: BorderSide.strokeAlignInside, + width: 1.5), + borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + spacing: 4, + children: [ + Row( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ tiamat.Text( - (responses?.length ?? 0).toString(), - color: bodyColor, - ) - ], - ), - if (showResults) - LinearProgressIndicator( - value: (responses?.length ?? 0) / mostVoted, - color: bodyColor), - ], + answer.answer, + ), + if (showResults) + Row( + spacing: 8, + children: [ + SizedBox( + width: 50, + child: ReadIndicator( + spacing: 10, + room: widget.timeline.room, + users: responses ?? {}), + ), + tiamat.Text( + (responses?.length ?? 0).toString(), + ) + ], + ) + ], + ), + if (showResults) + LinearProgressIndicator( + value: totalVotes == 0 + ? 0 + : (responses?.length ?? 0) / totalVotes, + ), + ], + ), ), ), ), diff --git a/commet/lib/ui/molecules/timeline_events/timeline_event_menu.dart b/commet/lib/ui/molecules/timeline_events/timeline_event_menu.dart index 3934b7bf3..4c1ff7a56 100644 --- a/commet/lib/ui/molecules/timeline_events/timeline_event_menu.dart +++ b/commet/lib/ui/molecules/timeline_events/timeline_event_menu.dart @@ -5,6 +5,7 @@ import 'package:commet/client/components/emoticon_recent/recent_emoticon_compone import 'package:commet/client/components/message_effects/message_effect_component.dart'; import 'package:commet/client/components/photo_album_room/photo_album_room_component.dart'; import 'package:commet/client/components/pinned_messages/pinned_messages_component.dart'; +import 'package:commet/client/components/polls/poll_component.dart'; import 'package:commet/client/components/push_notification/notification_content.dart'; import 'package:commet/client/components/push_notification/notification_manager.dart'; import 'package:commet/client/timeline.dart'; @@ -84,6 +85,12 @@ class TimelineEventMenu { name: "promptRetryEventSend", ); + String get promptEndPoll => Intl.message( + "End Poll", + desc: "Prompt the user to end a poll", + name: "promptEndPoll", + ); + TimelineEventMenu({ required this.timeline, required this.event, @@ -103,6 +110,7 @@ class TimelineEventMenu { bool hasEffect = false; bool canReply = false; bool canDeleteEvent = false; + bool canEndPoll = false; bool canRetrySend = event.status != TimelineEventStatus.synced; bool canCancelSend = event.status != TimelineEventStatus.synced; @@ -111,6 +119,7 @@ class TimelineEventMenu { var emoticons = timeline.room.getComponent(); var pins = timeline.room.getComponent(); var photos = timeline.room.getComponent(); + var polls = timeline.client.getComponent(); if (event.status == TimelineEventStatus.synced) { canEditEvent = event is TimelineEventMessage && @@ -143,6 +152,11 @@ class TimelineEventMenu { canCopy = event is TimelineEventMessage; + if (polls?.isPollEvent(event) == true && + polls?.canEndPoll(timeline.room, event, timeline) == true) { + canEndPoll = true; + } + canEditPinState = pins?.canPinMessages == true && (event is TimelineEventMessage || event is TimelineEventSticker || @@ -202,6 +216,17 @@ class TimelineEventMenu { } primaryActions = [ + if (canEndPoll) + TimelineEventMenuEntry( + name: promptEndPoll, + icon: Icons.poll, + action: (context) async { + if (await AdaptiveDialog.confirmation(context, + title: promptEndPoll, + prompt: "Are you sure you want to end the poll?") == + true) polls?.endPoll(timeline.room, event); + }, + ), if (canRetrySend) TimelineEventMenuEntry( name: promptRetryEventSend, diff --git a/tiamat/lib/atoms/circle_button.dart b/tiamat/lib/atoms/circle_button.dart index c63c7bb3e..6a8e4bbac 100644 --- a/tiamat/lib/atoms/circle_button.dart +++ b/tiamat/lib/atoms/circle_button.dart @@ -40,9 +40,11 @@ class CircleButton extends StatelessWidget { splashColor: Theme.of(context) .colorScheme .onSecondaryContainer, // Splash color - onTap: () { - onPressed?.call(); - }, + onTap: onPressed == null + ? null + : () { + onPressed?.call(); + }, child: SizedBox( width: radius * 2, height: radius * 2, From 0afff163f0480d24939724c06c31ae910759e929 Mon Sep 17 00:00:00 2001 From: Airyzz <36567925+Airyzz@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:25:12 +1030 Subject: [PATCH 5/7] Update poll_creator.dart --- commet/lib/ui/molecules/poll_creator.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/commet/lib/ui/molecules/poll_creator.dart b/commet/lib/ui/molecules/poll_creator.dart index 02dda90a5..f94b998d0 100644 --- a/commet/lib/ui/molecules/poll_creator.dart +++ b/commet/lib/ui/molecules/poll_creator.dart @@ -1,5 +1,4 @@ import 'package:commet/client/components/polls/poll_component.dart'; -import 'package:commet/ui/navigation/adaptive_dialog.dart'; import 'package:flutter/material.dart'; import 'package:tiamat/tiamat.dart' as tiamat; From 65d3f40a6326b878a7f64a1876408278e837fa1c Mon Sep 17 00:00:00 2001 From: Airyzz <36567925+Airyzz@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:16:06 +1030 Subject: [PATCH 6/7] Update matrix_poll_component.dart --- .../polls/matrix_poll_component.dart | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/commet/lib/client/matrix/components/polls/matrix_poll_component.dart b/commet/lib/client/matrix/components/polls/matrix_poll_component.dart index 6bb43b26d..b72e5d3ed 100644 --- a/commet/lib/client/matrix/components/polls/matrix_poll_component.dart +++ b/commet/lib/client/matrix/components/polls/matrix_poll_component.dart @@ -21,20 +21,33 @@ class MatrixPollComponent implements PollComponent { return e.event.type == "org.matrix.msc3381.poll.start"; } - @override - List getAllowedPollAnswers(TimelineEvent event) { + matrix.PollEventContent _parse(TimelineEvent event) { var mxEvent = (event as MatrixTimelineEvent).event; + var c = mxEvent.content; + + if (c.containsKey(matrix.PollEventContent.mTextJsonKey) == false) { + if (c["body"] is String) { + c[matrix.PollEventContent.mTextJsonKey] = c["body"]; + } else { + c[matrix.PollEventContent.mTextJsonKey] = ""; + } + } + + return matrix.PollEventContent.fromJson(c); + } - return mxEvent.parsedPollEventContent.pollStartContent.answers + @override + List getAllowedPollAnswers(TimelineEvent event) { + return _parse(event) + .pollStartContent + .answers .map((i) => PollAnswer(i.id, i.mText)) .toList(); } @override int getMaxSelections(TimelineEvent event) { - var mxEvent = (event as MatrixTimelineEvent).event; - - return mxEvent.parsedPollEventContent.pollStartContent.maxSelections; + return _parse(event).pollStartContent.maxSelections; } @override @@ -61,9 +74,7 @@ class MatrixPollComponent implements PollComponent { @override String getPollQuestion(TimelineEvent event) { - var mxEvent = (event as MatrixTimelineEvent).event; - - return mxEvent.parsedPollEventContent.pollStartContent.question.mText; + return _parse(event).pollStartContent.question.mText; } @override @@ -77,8 +88,7 @@ class MatrixPollComponent implements PollComponent { bool shouldShowResults(TimelineEvent event, Timeline timeline) { var mxEvent = (event as MatrixTimelineEvent).event; - if (mxEvent.parsedPollEventContent.pollStartContent.kind == - matrix.PollKind.disclosed) { + if (_parse(event).pollStartContent.kind == matrix.PollKind.disclosed) { return true; } From 6411a53978c20e6b7c8fe40c75c73da9f08bc72c Mon Sep 17 00:00:00 2001 From: Airyzz <36567925+Airyzz@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:15:25 +1030 Subject: [PATCH 7/7] Update timeline_event_view_poll.dart --- .../timeline_events/events/timeline_event_view_poll.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart index fbad8a8e8..a8e9c1e17 100644 --- a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart @@ -1,6 +1,7 @@ import 'package:commet/client/components/polls/poll_component.dart'; import 'package:commet/client/timeline.dart'; import 'package:commet/client/timeline_events/timeline_event.dart'; +import 'package:commet/config/layout_config.dart'; import 'package:commet/ui/molecules/read_indicator.dart'; import 'package:commet/ui/molecules/timeline_events/layouts/timeline_event_layout_message.dart'; import 'package:commet/ui/molecules/timeline_events/timeline_event_layout.dart'; @@ -62,7 +63,8 @@ class _TimelineEventViewPollState extends State senderAvatar: senderAvatar, showSender: true, formattedContent: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 500), + constraints: + BoxConstraints(maxWidth: Layout.desktop ? 500 : double.infinity), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,