diff --git a/lib/controllers/event.dart b/lib/controllers/event.dart index 44a44f0..43ed9e8 100644 --- a/lib/controllers/event.dart +++ b/lib/controllers/event.dart @@ -1,4 +1,3 @@ -import "package:collection/collection.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/rooms.dart"; @@ -10,11 +9,14 @@ class EventController(final GetEventRequest request) @override Future build() async { final event = ref.watch( - RoomsController.provider.select( - (rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull( - (event) => event.eventId == request.eventId, - ), - ), + RoomsController.provider.select((rooms) { + final room = rooms[request.roomId]; + + return switch (room?.eventIds[request.eventId]) { + final rowId? => room?.events[rowId], + null => null, + }; + }), ); if (event != null) return event; diff --git a/lib/controllers/poll_votes.dart b/lib/controllers/poll_votes.dart new file mode 100644 index 0000000..c881a0c --- /dev/null +++ b/lib/controllers/poll_votes.dart @@ -0,0 +1,132 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/room_power_levels.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/event_id_is_fake.dart"; +import "package:nexus/models/configs/event.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/poll_end.dart"; +import "package:nexus/models/content/poll_response.dart"; +import "package:nexus/models/content/poll_start.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/poll_votes.dart"; + +class PollVotesController(final EventConfig config) + extends AsyncNotifier { + @override + Future build() async { + // Only what's needed, so e.g. reactions don't cause refetches + final (poll, localRelated) = ref.watch( + RoomsController.provider.select((rooms) { + final room = rooms[config.roomId]; + final event = room?.events[config.eventRowId]; + if (room == null || event == null) { + return (null, const IList.empty()); + } + + final edited = event.lastEditRowId == 0 + ? null + : room.events[event.lastEditRowId]; + + return ( + ( + eventId: event.eventId, + sender: event.sender, + // Edits can change the answers + content: (edited ?? event).content, + ), + (room.relations[event.eventId] ?? const ISet.empty()) + .map((rowId) => room.events[rowId]) + .nonNulls + .where((related) => related.relationType == "m.reference") + .toIList(), + ); + }), + ); + final powerLevels = ref.watch( + RoomPowerLevelsController.provider(config.roomId), + ); + + if (poll == null) return const PollVotes(); + final PollStart(:answers, :selectionLimit) = switch (poll.content) { + PollStartContent(:final poll) => poll, + _ => const PollStart(), + }; + + final fetched = await ref + .read(ClientController.provider.notifier) + .getRelatedEvents( + .new( + roomId: config.roomId, + eventId: poll.eventId, + relationType: "m.reference", + ), + ); + + final related = + IMap.fromIterable( + (fetched ?? const IList.empty()).addAll(localRelated), + keyMapper: (event) => event.rowId, + valueMapper: (event) => event, + ).values + // gomuks' database has unsent local echoes too + .where((event) => event.redactedBy == null && !event.eventId.isFake) + .toIList(); + + final end = related + .where( + (event) => + event.content is PollEndContent && + powerLevels.canEndPoll(event.sender, pollSender: poll.sender), + ) + .sortedBy((event) => event.timestamp) + .firstOrNull; + final endedAt = end?.timestamp; + + bool beforeEnd(Event event) => + endedAt == null || !event.timestamp.isAfter(endedAt); + + final answerIds = answers.map((answer) => answer.id).toISet(); + + return .new( + endEventId: end?.eventId, + undecryptable: related + .where( + (event) => event.content is EncryptedContent && beforeEnd(event), + ) + .length, + // Each user's latest response before the end counts, even if invalid + votes: related + .where( + (event) => + event.type == EventType.pollResponse.type && beforeEnd(event), + ) + .toIList() + .sort((a, b) => a.timestamp.compareTo(b.timestamp)) + .fold(const .empty(), (votes, event) { + // Truncated before removing duplicates, as MSC3381 specifies + final picked = switch (event.content) { + PollResponseContent(:final answers) => + answers.take(selectionLimit).toISet(), + _ => null, + }; + + return votes.add( + event.sender, + // Spoiled responses count as no answers + picked != null && picked.every(answerIds.contains) + ? picked.toIList() + : const .empty(), + ); + }), + ); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose( + PollVotesController.new, + ); +} diff --git a/lib/controllers/reactions.dart b/lib/controllers/reactions.dart index b3584c8..0c30128 100644 --- a/lib/controllers/reactions.dart +++ b/lib/controllers/reactions.dart @@ -2,10 +2,10 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/rooms.dart"; -import "package:nexus/models/configs/reactions.dart"; +import "package:nexus/models/configs/event.dart"; import "package:nexus/models/content/reaction.dart"; -class ReactionsController(final ReactionsConfig config) +class ReactionsController(final EventConfig config) extends AsyncNotifier>> { @override Future>> build() async { @@ -48,6 +48,6 @@ class ReactionsController(final ReactionsConfig config) AsyncNotifierProvider.family< ReactionsController, IMap>, - ReactionsConfig + EventConfig >(ReactionsController.new); } diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index bd4808d..913c52a 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -8,8 +8,12 @@ import "package:fluttertagger/fluttertagger.dart"; import "package:nexus/controllers/attachment.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/send_failed.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/content/poll_end.dart"; +import "package:nexus/models/content/poll_response.dart"; +import "package:nexus/models/content/poll_start.dart"; import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/direction.dart"; @@ -340,10 +344,10 @@ class RoomChatController(final ChatParams info) ); } - /// Sends [image] as a sticker, as a reply to [repliedEventRowId] if set. - Future sendSticker( - String shortcode, - PackImage image, { + /// Sends [content], as a reply to [repliedEventRowId] if set. + Future sendWithReply( + EventType type, + Content content, { bool shouldMention = true, int? repliedEventRowId, }) async { @@ -360,12 +364,8 @@ class RoomChatController(final ChatParams info) .sendEvent( .new( roomId: info.roomId, - type: EventType.sticker.type, - content: StickerContent( - body: image.body ?? shortcode, - info: image.info ?? const .new(), - url: image.url, - ), + type: type.type, + content: content, mentions: Mentions( userIds: [ if (shouldMention && repliedEvent != null) repliedEvent.sender, @@ -387,6 +387,70 @@ class RoomChatController(final ChatParams info) ); } + /// Sends [image] as a sticker, as a reply to [repliedEventRowId] if set. + Future sendSticker( + String shortcode, + PackImage image, { + bool shouldMention = true, + int? repliedEventRowId, + }) => sendWithReply( + .sticker, + StickerContent( + body: image.body ?? shortcode, + info: image.info ?? const .new(), + url: image.url, + ), + shouldMention: shouldMention, + repliedEventRowId: repliedEventRowId, + ); + + Future sendPoll( + PollStart poll, { + bool shouldMention = true, + int? repliedEventRowId, + }) => sendWithReply( + .pollStart, + PollStartContent(poll: poll), + shouldMention: shouldMention, + repliedEventRowId: repliedEventRowId, + ); + + /// Completes once the event has come down sync. + Future sendPollReference( + Event poll, + EventType type, + Content content, + ) async { + final event = await ref + .read(ClientController.provider.notifier) + .sendEvent( + .new( + roomId: info.roomId, + type: type.type, + content: content, + relatesTo: poll.eventId, + relationType: "m.reference", + synchronous: true, + ), + ); + + if (event.sendFailed) throw Exception(event.sendError); + + await ref + .read(RoomsController.provider.notifier) + .awaitEvent(info.roomId, event.eventId); + } + + /// An empty [answers] removes the vote. + Future votePoll(Event poll, IList answers) => sendPollReference( + poll, + .pollResponse, + PollResponseContent(answers: answers), + ); + + Future endPoll(Event poll) => + sendPollReference(poll, .pollEnd, const PollEndContent()); + static final provider = AsyncNotifierProvider.family .autoDispose( RoomChatController.new, diff --git a/lib/controllers/rooms.dart b/lib/controllers/rooms.dart index d52b317..67e219b 100644 --- a/lib/controllers/rooms.dart +++ b/lib/controllers/rooms.dart @@ -82,6 +82,35 @@ class RoomsController extends Notifier> { ), ); + static IMap> addRelations( + IMap> relations, + Iterable events, + ) => events.fold( + relations, + (acc, event) => switch (event.relatesTo) { + final relatesTo? => acc.update( + relatesTo, + (rowIds) => rowIds.add(event.rowId), + ifAbsent: () => .new([event.rowId]), + ), + null => acc, + }, + ); + + /// IDs replace previous ones for the same row, e.g. once a local echo is sent. + static IMap addEventIds( + IMap eventIds, + IMap existing, + Iterable events, + ) => events.fold(eventIds, (acc, event) { + final previousId = existing[event.rowId]?.eventId; + + return (previousId != null && previousId != event.eventId + ? acc.remove(previousId) + : acc) + .add(event.eventId, event.rowId); + }); + void update( IMap rooms, [ ISet leftRooms = const .empty(), @@ -109,6 +138,15 @@ class RoomsController extends Notifier> { events: incoming.events.isEmpty ? existing.events : existing.events.addAll(incoming.events), + relations: addRelations( + existing.relations, + incoming.events.values, + ), + eventIds: addEventIds( + existing.eventIds, + existing.events, + incoming.events.values, + ), state: incoming.state.entries.fold( existing.state, (previousValue, event) => previousValue.add( @@ -129,6 +167,12 @@ class RoomsController extends Notifier> { ) ?? incoming.copyWith( receipts: mergeReceipts(const .empty(), incoming.receipts), + relations: addRelations(const .empty(), incoming.events.values), + eventIds: addEventIds( + const .empty(), + const .empty(), + incoming.events.values, + ), ), ); }); @@ -144,12 +188,19 @@ class RoomsController extends Notifier> { String type, String eventId, { String stateKey = "", - }) { - bool arrived(IMap rooms) => - switch (rooms[roomId]?.state[type]?[stateKey]) { - final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId, - null => false, - }; + }) => awaitRooms( + (rooms) => switch (rooms[roomId]?.state[type]?[stateKey]) { + final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId, + null => false, + }, + ); + + /// Waits for a sent event to come down sync, giving up after a while. + Future awaitEvent(String roomId, String eventId) => awaitRooms( + (rooms) => rooms[roomId]?.eventIds.containsKey(eventId) ?? false, + ); + + Future awaitRooms(bool Function(IMap rooms) arrived) { if (arrived(state)) return .value(); final completer = Completer(); diff --git a/lib/helpers/extensions/is_shown_as_message.dart b/lib/helpers/extensions/is_shown_as_message.dart index a78810b..6df7d2c 100644 --- a/lib/helpers/extensions/is_shown_as_message.dart +++ b/lib/helpers/extensions/is_shown_as_message.dart @@ -1,15 +1,19 @@ 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"; extension IsShownAsMessage on Event { + /// Whether this is a message, sticker or poll. + bool get hasMessageContent => switch (content) { + MessageContent() || StickerContent() || PollStartContent() => true, + _ => false, + }; + /// Whether this is shown in the timeline as a message, with MessageRenderer. bool get isShownAsMessage => redactedBy == null && relationType != "m.replace" && - switch (content) { - MessageContent() || EncryptedContent() || StickerContent() => true, - _ => false, - }; + (hasMessageContent || content is EncryptedContent); } diff --git a/lib/helpers/extensions/send_failed.dart b/lib/helpers/extensions/send_failed.dart new file mode 100644 index 0000000..cb0ba78 --- /dev/null +++ b/lib/helpers/extensions/send_failed.dart @@ -0,0 +1,6 @@ +import "package:nexus/models/event.dart"; + +extension SendFailed on Event { + /// gomuks marks events that are still sending as "not sent" (and often forgets to clear it at all) + bool get sendFailed => sendError != null && sendError != "not sent"; +} diff --git a/lib/models/configs/event.dart b/lib/models/configs/event.dart new file mode 100644 index 0000000..9f41fbe --- /dev/null +++ b/lib/models/configs/event.dart @@ -0,0 +1,16 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "event.freezed.dart"; +part "event.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const EventConfig({ + required final String roomId, + required final int eventRowId, +}) with _$EventConfig { + Map toJson() => _$EventConfigToJson(this); + + factory EventConfig.fromJson(Map json) => + _$EventConfigFromJson(json); +} diff --git a/lib/models/configs/reactions.dart b/lib/models/configs/reactions.dart deleted file mode 100644 index b9de793..0000000 --- a/lib/models/configs/reactions.dart +++ /dev/null @@ -1,16 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -part "reactions.freezed.dart"; -part "reactions.g.dart"; - -@Freezed(toJson: false, fromJson: false) -@JsonSerializable() -class const ReactionsConfig({ - required final String roomId, - required final int eventRowId, -}) with _$ReactionsConfig { - Map toJson() => _$ReactionsConfigToJson(this); - - factory ReactionsConfig.fromJson(Map json) => - _$ReactionsConfigFromJson(json); -} diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index 2809245..aa564e2 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -17,6 +17,9 @@ import "package:nexus/models/content/topic.dart"; import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/content/poll_end.dart"; +import "package:nexus/models/content/poll_response.dart"; +import "package:nexus/models/content/poll_start.dart"; class const Content({final String? parseError, final StackTrace? stackTrace}) { factory Content.fromJson(Map json) => const Content(); @@ -64,5 +67,11 @@ enum EventType( pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), imagePack("m.room.image_pack", ImagePackContent.fromJson), legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson), + pollStart("org.matrix.msc3381.poll.start", PollStartContent.fromJson), + pollResponse( + "org.matrix.msc3381.poll.response", + PollResponseContent.fromJson, + ), + pollEnd("org.matrix.msc3381.poll.end", PollEndContent.fromJson), message("m.room.message", MessageContent.fromJson), } diff --git a/lib/models/content/poll_end.dart b/lib/models/content/poll_end.dart new file mode 100644 index 0000000..51fe58c --- /dev/null +++ b/lib/models/content/poll_end.dart @@ -0,0 +1,12 @@ +import "package:nexus/models/content/content.dart"; + +class const PollEndContent() extends Content { + factory PollEndContent.fromJson(Map json) => + const PollEndContent(); + + @override + Map toJson() => { + "org.matrix.msc3381.poll.end": {}, + "org.matrix.msc1767.text": "Ended the poll", + }; +} diff --git a/lib/models/content/poll_response.dart b/lib/models/content/poll_response.dart new file mode 100644 index 0000000..f151cdf --- /dev/null +++ b/lib/models/content/poll_response.dart @@ -0,0 +1,23 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "poll_response.freezed.dart"; +part "poll_response.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const PollResponseContent({ + @JsonKey(readValue: PollResponseContent.answersFromJson) + final IList answers = const .empty(), +}) extends Content with _$PollResponseContent { + static List? answersFromJson(Map json, _) => + json["org.matrix.msc3381.poll.response"]?["answers"]; + + @override + Map toJson() => { + "org.matrix.msc3381.poll.response": {"answers": answers.toList()}, + }; + + factory PollResponseContent.fromJson(Map json) => + _$PollResponseContentFromJson(json); +} diff --git a/lib/models/content/poll_start.dart b/lib/models/content/poll_start.dart new file mode 100644 index 0000000..197ca69 --- /dev/null +++ b/lib/models/content/poll_start.dart @@ -0,0 +1,90 @@ +import "dart:math"; + +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "poll_start.freezed.dart"; +part "poll_start.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const PollStartContent({ + @JsonKey(name: "org.matrix.msc3381.poll.start") required final PollStart poll, +}) extends Content with _$PollStartContent { + @override + Map toJson() => { + "org.matrix.msc3381.poll.start": poll.toJson(), + // Shown by clients without poll support + "org.matrix.msc1767.text": [ + poll.question, + ...poll.answers.mapIndexed( + (index, answer) => "${index + 1}. ${answer.text}", + ), + ].join("\n"), + }; + + factory PollStartContent.fromJson(Map json) => + _$PollStartContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PollStart({ + @JsonKey(readValue: PollStart.questionFromJson) final String question = "", + final int? maxSelections, + @JsonKey(readValue: PollStart.answersFromJson) + final IList answers = const .empty(), +}) with _$PollStart { + static const maxAnswers = 20; + + static String? extensibleTextFromJson(Object? json) => switch (json) { + {"org.matrix.msc1767.text": final String text} => text, + {"body": final String body} => body, + {"org.matrix.msc1767.message": final List message} => + message + .whereType() + .map((part) => part["body"]) + .whereType() + .firstOrNull, + _ => null, + }; + + static String? questionFromJson(Map json, String key) => + PollStart.extensibleTextFromJson(json[key]); + + static List? answersFromJson( + Map json, + String key, + ) => (json[key] as List?)?.take(maxAnswers).toList(); + + /// [maxSelections], defaulting to 1, and at most the number of answers + int get selectionLimit => + (maxSelections ?? 1).clamp(1, max(answers.length, 1)); + + Map toJson() => { + ..._$PollStartToJson(this), + // We don't allow undisclosed polls as they make no sense + "kind": "org.matrix.msc3381.poll.disclosed", + "question": {"org.matrix.msc1767.text": question}, + "answers": answers.map((answer) => answer.toJson()).toList(), + }; + + factory PollStart.fromJson(Map json) => + _$PollStartFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const PollAnswer({ + required final String id, + @JsonKey(readValue: PollAnswer.textFromJson) final String text = "", +}) with _$PollAnswer { + static String? textFromJson(Map json, _) => + PollStart.extensibleTextFromJson(json); + + Map toJson() => {"id": id, "org.matrix.msc1767.text": text}; + + factory PollAnswer.fromJson(Map json) => + _$PollAnswerFromJson(json); +} diff --git a/lib/models/poll_votes.dart b/lib/models/poll_votes.dart new file mode 100644 index 0000000..47895b9 --- /dev/null +++ b/lib/models/poll_votes.dart @@ -0,0 +1,25 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +class const PollVotes({ + /// Each user's answers, by user ID + final IMap> votes = const .empty(), + final String? endEventId, + final int undecryptable = 0, +}) { + bool get hasEnded => endEventId != null; + + IMap> get votersByAnswer => votes.entries.fold( + const .empty(), + (acc, entry) => entry.value.fold( + acc, + (acc, answer) => acc.update( + answer, + (voters) => voters.add(entry.key), + ifAbsent: () => .new([entry.key]), + ), + ), + ); + + int get voterCount => + votes.values.where((answers) => answers.isNotEmpty).length; +} diff --git a/lib/models/room.dart b/lib/models/room.dart index f02c6ac..b809a23 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -24,6 +24,14 @@ class const Room({ @JsonKey(fromJson: Room.eventsJsonToIMap) final IMap events = const .empty(), + /// [relations] is an IMap of event ID to the eventRowIds relating to it + @JsonKey(includeFromJson: false, includeToJson: false) + final IMap> relations = const .empty(), + + /// [eventIds] is an IMap of event ID to eventRowId + @JsonKey(includeFromJson: false, includeToJson: false) + final IMap eventIds = const .empty(), + final bool reset = false, @JsonKey(includeFromJson: false, includeToJson: false) final bool resetClientSticky = false, diff --git a/lib/models/room_power_levels.dart b/lib/models/room_power_levels.dart index 028d161..a40c1a1 100644 --- a/lib/models/room_power_levels.dart +++ b/lib/models/room_power_levels.dart @@ -8,4 +8,7 @@ class const RoomPowerLevels({ double levelOf(String userId) => creators.contains(userId) ? double.infinity : (content.users[userId] ?? content.usersDefault).toDouble(); + + bool canEndPoll(String userId, {required String pollSender}) => + userId == pollSender || levelOf(userId) >= content.redact; } diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index b32d5dc..7727ce4 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -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 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(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 diff --git a/lib/widgets/composer/composer_replacement.dart b/lib/widgets/composer/composer_replacement.dart new file mode 100644 index 0000000..ef6ff9f --- /dev/null +++ b/lib/widgets/composer/composer_replacement.dart @@ -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), + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/create_poll_sheet.dart b/lib/widgets/create_poll_sheet.dart new file mode 100644 index 0000000..01a26b1 --- /dev/null +++ b/lib/widgets/create_poll_sheet.dart @@ -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>(.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, + ), + ], + ); + } +} diff --git a/lib/widgets/event_preview.dart b/lib/widgets/event_preview.dart index cedbe30..3de1eeb 100644 --- a/lib/widgets/event_preview.dart +++ b/lib/widgets/event_preview.dart @@ -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( diff --git a/lib/widgets/poll_option.dart b/lib/widgets/poll_option.dart new file mode 100644 index 0000000..ff01d4e --- /dev/null +++ b/lib/widgets/poll_option.dart @@ -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), + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 49c7b19..52b72fc 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -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( diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart index d0e4f08..d54756c 100644 --- a/lib/widgets/renderers/message.dart +++ b/lib/widgets/renderers/message.dart @@ -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) { diff --git a/lib/widgets/renderers/poll.dart b/lib/widgets/renderers/poll.dart new file mode 100644 index 0000000..84567cb --- /dev/null +++ b/lib/widgets/renderers/poll.dart @@ -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 run( + ({String? answerId}) action, + Future 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 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"), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index bd83ada..de12d2a 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -38,6 +38,9 @@ final class const RoomChat({ Widget build(BuildContext context, WidgetRef ref) { final relatedEvent = useState(null); final relationType = useState(RelationType.reply); + final repliedEventRowId = relationType.value == .reply + ? relatedEvent.value + : null; final pendingJump = ref.read(JumpRequestController.provider); final contextualEvent = useState( 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: () { diff --git a/pubspec.lock b/pubspec.lock index ef07b85..cb20bb6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,14 +89,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" - asn1lib: - dependency: transitive - description: - name: asn1lib - sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" - url: "https://pub.dev" - source: hosted - version: "1.6.5" async: dependency: transitive description: @@ -313,22 +305,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - dynamic_polls: - dependency: "direct main" - description: - name: dynamic_polls - sha256: "58cd1fafbbaba6b485a61692aa3bd33ded6f0f61734543b6c754511c362d8ae1" - url: "https://pub.dev" - source: hosted - version: "0.0.8" - encrypt: - dependency: transitive - description: - name: encrypt - sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" - url: "https://pub.dev" - source: hosted - version: "5.0.3" equatable: dependency: transitive description: @@ -623,14 +599,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" - get_x_storage: - dependency: transitive - description: - name: get_x_storage - sha256: "597cd8f6ef8b01c747a6d95ff7b0310066f6c82ddf5565ca371f047f316fe43e" - url: "https://pub.dev" - source: hosted - version: "0.2.0" glob: dependency: transitive description: @@ -791,14 +759,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -1203,14 +1163,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" - url: "https://pub.dev" - source: hosted - version: "3.9.1" pool: dependency: transitive description: @@ -1291,14 +1243,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.9" - rxdart: - dependency: transitive - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" safe_local_storage: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d561ee1..ca495db 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,7 +81,6 @@ dependencies: fluttertagger: git: url: https://github.com/Henry-Hiles/fluttertagger # Fixes the last tag search being dropped from formattedText - dynamic_polls: 0.0.8 flutter_hooks: 0.21.3+1 ffi: 2.2.0 hooks: 2.2.0