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..78580cf61 --- /dev/null +++ b/commet/lib/client/components/polls/poll_component.dart @@ -0,0 +1,50 @@ +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); +} + +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); + + bool isPollEvent(TimelineEvent event); + + String getPollQuestion(TimelineEvent event); + + int getMaxSelections(TimelineEvent event); + + bool shouldShowResults(TimelineEvent event, Timeline timeline); + + bool isFinished(TimelineEvent event, Timeline timeline); + + 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 new file mode 100644 index 000000000..b72e5d3ed --- /dev/null +++ b/commet/lib/client/matrix/components/polls/matrix_poll_component.dart @@ -0,0 +1,135 @@ +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'; + +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"; + } + + 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); + } + + @override + List getAllowedPollAnswers(TimelineEvent event) { + return _parse(event) + .pollStartContent + .answers + .map((i) => PollAnswer(i.id, i.mText)) + .toList(); + } + + @override + int getMaxSelections(TimelineEvent event) { + return _parse(event).pollStartContent.maxSelections; + } + + @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) { + return _parse(event).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 (_parse(event).pollStartContent.kind == matrix.PollKind.disclosed) { + return true; + } + + return mxEvent + .getPollHasBeenEnded((timeline as MatrixTimeline).matrixTimeline!); + } + + @override + bool isFinished(TimelineEvent event, Timeline timeline) { + var mxEvent = (event as MatrixTimelineEvent).event; + + 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..f94b998d0 --- /dev/null +++ b/commet/lib/ui/molecules/poll_creator.dart @@ -0,0 +1,190 @@ +import 'package:commet/client/components/polls/poll_component.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 new file mode 100644 index 000000000..a8e9c1e17 --- /dev/null +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_poll.dart @@ -0,0 +1,246 @@ +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'; +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; + +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; + int maxSelections = 0; + bool showResults = false; + bool isFinished = false; + List allowedAnswers = []; + Map> pollResponses = {}; + TimelineEvent? event; + + @override + void initState() { + polls = widget.timeline.client.getComponent(); + setStateFromIndex(widget.initialIndex); + super.initState(); + } + + @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, + senderAvatar: senderAvatar, + showSender: true, + formattedContent: ConstrainedBox( + constraints: + BoxConstraints(maxWidth: Layout.desktop ? 500 : double.infinity), + 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, 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") + ], + ), + ], + ) + ], + ), + ), + ); + } + + @override + void update(int newIndex) { + setStateFromIndex(newIndex); + } + + void setStateFromIndex(int index) { + setState(() { + 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; + maxSelections = polls!.getMaxSelections(e); + showResults = polls!.shouldShowResults(e, widget.timeline); + body = polls!.getPollQuestion(e); + isFinished = polls!.isFinished(e, widget.timeline); + allowedAnswers = polls!.getAllowedPollAnswers(e); + pollResponses = polls!.getPollResponses(widget.timeline, e); + event = e; + }); + } + + Widget buildAnswer(PollAnswer answer, {int totalVotes = 1}) { + var responses = pollResponses[answer.id]; + + var isOurResponse = + responses?.contains(widget.timeline.client.self!.identifier) == true; + + return Material( + clipBehavior: Clip.antiAlias, + color: ColorScheme.of(context).surfaceContainerLow, + borderRadius: BorderRadius.circular(8), + child: InkWell( + 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: isFinished + ? 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: 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( + 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 298b6074c..1cd93b262 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/matrix/timeline_events/matrix_timeline_event.dart'; @@ -87,6 +88,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, @@ -106,6 +113,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; @@ -114,6 +122,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 && @@ -146,6 +155,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 || @@ -205,6 +219,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 (event is TimelineEventEncrypted) TimelineEventMenuEntry( name: "Retry Decrypt", 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; 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,