From b29fd2699f4cc91fa01bfd796186612df987b08a Mon Sep 17 00:00:00 2001 From: XSS3cut10n3r Date: Tue, 10 Nov 2026 04:10:56 +0000 Subject: [PATCH 1/3] Define quoted replies and bounded message edit history Document encrypted payloads, ordering, recovery and the requested mobile interactions before implementation. Co-authored-by: Codex --- .../0022-quoted-replies-and-message-edits.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/decisions/0022-quoted-replies-and-message-edits.md diff --git a/docs/decisions/0022-quoted-replies-and-message-edits.md b/docs/decisions/0022-quoted-replies-and-message-edits.md new file mode 100644 index 0000000..b750db7 --- /dev/null +++ b/docs/decisions/0022-quoted-replies-and-message-edits.md @@ -0,0 +1,81 @@ +# Quoted replies and message edits + +Status: implementation requested by the owner, 2026-11-10. Review before merge. +Extends decision 0021 without changing keys, ratchet construction or server rules. + +## Interaction reference + +Follow Signal iOS: swipe right or choose Reply, show a cancellable quote above +the input, include the quote in the sent bubble, and tap it to locate the original. +Edit from the held-message menu or double-tap an eligible outgoing message. +Use a separate editing draft, a checkmark to submit, and Cancel to restore the +ordinary draft. Show Edited beside the timestamp and a version-history sheet. +Allow ten edit revisions within 24 hours of original sending. No delivery/read +status, typing or presence indicators are added. Fireplace colors remain. + +References: [replies](https://support.signal.org/hc/en-us/articles/6851465208986-Reply-to-a-specific-message) +and [editing](https://support.signal.org/hc/en-us/articles/6255134251546-Edit-Message). + +## Encrypted payloads + +Plain text stays v1. A reply is +`{v:3,type:'text',body,quote:{targetId,senderUid,body},ts}`. Quote text is a +snapshot of at most 1,024 Unicode characters, never an embedded message tree. +The normal body keeps the 16,384-character limit. Validate bounded identifiers, +quote shape and lengths before accepting it. A quoted snapshot is content from +the replying account, not cryptographic proof that its attributed author wrote +it. Resolve navigation against local history; never retrieve plaintext remotely. +Unknown/undecryptable originals still allow the encrypted snapshot to be read; +tapping explains that the original is unavailable. Known deleted originals +scrub quote bodies, including on replay. Editing the original does not rewrite +previously sent quotes. + +An edit is `{v:3,type:'edit',targetId,body,revision,ts}`. Revisions are integers +1 through 10. The authenticated envelope sender must match the original author; +payloads cannot choose an actor. Edits are hidden control records and consume a +normal ratchet key for every receiving device, including linked-device copies. +Only accepted conversations may send these content operations. The sender +checks ownership, readable target, original age, limits and a changed body under +the existing send mutex before encryption. Receivers accept late delivery of +authorized revisions: an offline timestamp cannot prove when a user edited. + +Keep a bounded history of the original plus ten revision slots. For concurrent +linked-device edits at the same revision, the greatest `(deviceId,eventId)` wins +that slot. The greatest revision is displayed; arrival order cannot roll it back. +Conflicting versions of the same slot are not separate history entries. Choose +the next revision above every observed local attempt, including an uncertain +send, so an unresolved attempt never reuses a slot. This conservatively consumes +an edit allowance when publication cannot be established. No automatic retry. + +Unknown targets retain hidden edits until the original arrives. Undecryptable +targets never become readable from an edit. Local tombstones and remote deletion +dominate edits, erase all versions and scrub hidden edit content. Reactions stay +attached to the original message ID. Edits cannot change the reply reference. + +## Durability, privacy and compatibility + +Store quotes/history only in existing encrypted local files and receive journals; +old optional fields default to empty. Project controls in linear history passes +and bounded per-message revision work. Preserve reply metadata through unknown +outcomes, explicit status checks, local-save repair and explicit new-copy sends. +Ordinary drafts, reply targets and edit drafts are account-scoped; dismissing an +edit restores ordinary text, and access changes revoke open content surfaces. + +Firestore retains exactly the existing encrypted envelope and metadata fields. +Each operation costs ordinary Spark message writes/reads. No new services, +collections, indexes, packages or plaintext server fields. Older clients can +read a reply's text but omit its quote, and cannot apply edits. Both participants +need updated clients for the complete experience. Remote erasure remains best +effort and cannot remove independent copies or older clients. + +## Tests + +Use real encryption with fake Firestore for both participants and linked devices; +test ordering, duplicate/replayed controls, forged authors, unknown/unreadable +targets, deletion dominance, size/revision/time limits and unknown outcomes. +Exercise encrypted disk reload and receive-journal recovery with metadata. +Keep chat simulations and the separate emulator rules suite green. UiFixture +tests and light/dark renders cover reply/cancel/send/jump, swipe, edit/cancel/save, +history, accessibility actions, enlarged text, keyboard and account transitions. + +AI assistance: OpenAI Codex wrote this design and the implementation/tests. From f3af5c3704f0d2093023a6a9b930ee810d03d868 Mon Sep 17 00:00:00 2001 From: XSS3cut10n3r Date: Tue, 10 Nov 2026 04:16:50 +0000 Subject: [PATCH 2/3] Add encrypted quoted replies and swipe-to-reply composition Keep quote metadata through recovery and linked copies, scrub deleted originals, and expose reply previews and local navigation with tests and render states. Co-authored-by: Codex --- lib/src/db/local_messages.dart | 10 + lib/src/db/message_projection.dart | 10 + lib/src/db/message_quote.dart | 45 ++++ lib/src/model/chat/chat_exceptions.dart | 3 + lib/src/model/chat/chat_service.dart | 34 +++ lib/src/model/chat/memory_pending.dart | 6 +- .../model/chat/message_compose_target.dart | 31 +++ lib/src/model/chat/message_content.dart | 72 ++++++ lib/src/model/chat/message_receiver.dart | 9 + lib/src/model/chat/message_sender.dart | 85 ++++--- lib/src/model/chat/pending_sends.dart | 2 + lib/src/model/chat/receive_journal.dart | 5 + lib/src/model/chat/send_controller.dart | 64 +++-- lib/src/model/chat/send_recovery.dart | 10 +- lib/src/view/chat/chat_composer.dart | 180 ++++++++------ lib/src/view/chat/chat_screen.dart | 131 +++++----- lib/src/view/chat/chat_timeline.dart | 3 + .../composer/message_composer_context.dart | 73 ++++++ lib/src/view/chat/confirm_another_copy.dart | 28 +++ lib/src/view/chat/message_menu.dart | 12 + lib/src/view/chat/widgets/message_bubble.dart | 232 +++++++++++------- .../chat/widgets/message_quote_preview.dart | 67 +++++ scripts/render/render_screens_test.dart | 2 + scripts/render/reply_edit_cases.dart | 41 ++++ test/db/message_quotes_test.dart | 64 +++++ test/model/chat/quoted_messages_test.dart | 57 +++++ test/model/chat/quoted_send_failure_test.dart | 59 +++++ test/support/ui_fixture.dart | 28 +++ test/view/chat/message_menu_test.dart | 14 +- test/view/chat/quoted_reply_test.dart | 78 ++++++ 30 files changed, 1159 insertions(+), 296 deletions(-) create mode 100644 lib/src/db/message_quote.dart create mode 100644 lib/src/model/chat/message_compose_target.dart create mode 100644 lib/src/model/chat/message_content.dart create mode 100644 lib/src/view/chat/composer/message_composer_context.dart create mode 100644 lib/src/view/chat/confirm_another_copy.dart create mode 100644 lib/src/view/chat/widgets/message_quote_preview.dart create mode 100644 scripts/render/reply_edit_cases.dart create mode 100644 test/db/message_quotes_test.dart create mode 100644 test/model/chat/quoted_messages_test.dart create mode 100644 test/model/chat/quoted_send_failure_test.dart create mode 100644 test/view/chat/quoted_reply_test.dart diff --git a/lib/src/db/local_messages.dart b/lib/src/db/local_messages.dart index 4065b86..b79ebc4 100644 --- a/lib/src/db/local_messages.dart +++ b/lib/src/db/local_messages.dart @@ -1,3 +1,5 @@ +import 'package:fireplace/src/db/message_quote.dart'; + import 'dart:async'; import 'package:fireplace/src/db/message_mutation.dart'; @@ -26,6 +28,7 @@ class LocalMessage { this.locallyDeleted = false, this.remotelyDeleted = false, this.mutation, + this.quote, Map reactions = const {}, Map reactionClocks = const {}, }) : reactions = Map.unmodifiable(reactions), @@ -42,12 +45,14 @@ class LocalMessage { final bool locallyDeleted; final bool remotelyDeleted; final MessageMutation? mutation; + final MessageQuote? quote; final Map reactions; final Map reactionClocks; bool get visible => !locallyDeleted && mutation == null; LocalMessage copyWith({ String? body, + MessageQuote? quote, bool? outgoing, MessageStatus? status, bool? remotelyDeleted, @@ -65,6 +70,7 @@ class LocalMessage { locallyDeleted: locallyDeleted, remotelyDeleted: remotelyDeleted ?? this.remotelyDeleted, mutation: mutation, + quote: quote ?? this.quote, reactions: reactions ?? this.reactions, reactionClocks: reactionClocks ?? this.reactionClocks, ); @@ -100,6 +106,7 @@ class LocalMessage { if (locallyDeleted) 'locallyDeleted': true, if (remotelyDeleted) 'remotelyDeleted': true, if (mutation != null) 'mutation': mutation!.toJson(), + if (quote != null) 'quote': quote!.toJson(), if (reactions.isNotEmpty) 'reactions': {for (final e in reactions.entries) e.key: e.value.toJson()}, if (reactionClocks.isNotEmpty) 'reactionClocks': reactionClocks, @@ -113,6 +120,9 @@ class LocalMessage { outgoing: j['outgoing'], sentAt: DateTime.fromMillisecondsSinceEpoch(j['sentAt']), body: j['body'], + quote: j['quote'] == null + ? null + : MessageQuote.fromJson(Map.from(j['quote'])), status: MessageStatus.values.byName(j['status']), locallyDeleted: j['locallyDeleted'] == true, remotelyDeleted: j['remotelyDeleted'] == true, diff --git a/lib/src/db/message_projection.dart b/lib/src/db/message_projection.dart index 0fad923..7a2409d 100644 --- a/lib/src/db/message_projection.dart +++ b/lib/src/db/message_projection.dart @@ -78,5 +78,15 @@ Map projectMessageRecords( next[event.id] = LocalMessage.tombstone(event.chatId, event.id); } } + for (final message in next.values.toList()) { + final quote = message.quote; + if (quote == null) continue; + final original = next[quote.targetId]; + if (message.remotelyDeleted || + original?.locallyDeleted == true || + original?.remotelyDeleted == true) { + next[message.id] = message.copyWith(quote: quote.withoutContent()); + } + } return next; } diff --git a/lib/src/db/message_quote.dart b/lib/src/db/message_quote.dart new file mode 100644 index 0000000..f2beae5 --- /dev/null +++ b/lib/src/db/message_quote.dart @@ -0,0 +1,45 @@ +class MessageQuote { + const MessageQuote({ + required this.targetId, + required this.senderUid, + required this.body, + this.unavailable = false, + }); + final String targetId, senderUid, body; + final bool unavailable; + + MessageQuote withoutContent() => MessageQuote( + targetId: targetId, + senderUid: senderUid, + body: '', + unavailable: true, + ); + + Map toJson() => { + 'targetId': targetId, + 'senderUid': senderUid, + 'body': body, + if (unavailable) 'unavailable': true, + }; + + factory MessageQuote.fromJson(Map json) { + final id = json['targetId'], uid = json['senderUid'], body = json['body']; + if (id is! String || + !RegExp(r'^[A-Za-z0-9_-]{1,128}$').hasMatch(id) || + uid is! String || + uid.isEmpty || + uid.length > 128 || + body is! String || + body.length > 2048 || + body.runes.length > 1024 || + (json.containsKey('unavailable') && json['unavailable'] is! bool)) { + throw const FormatException('Invalid quote'); + } + return MessageQuote( + targetId: id, + senderUid: uid, + body: body, + unavailable: json['unavailable'] == true, + ); + } +} diff --git a/lib/src/model/chat/chat_exceptions.dart b/lib/src/model/chat/chat_exceptions.dart index 10ef769..95904fe 100644 --- a/lib/src/model/chat/chat_exceptions.dart +++ b/lib/src/model/chat/chat_exceptions.dart @@ -1,3 +1,4 @@ +import 'package:fireplace/src/db/message_quote.dart'; import 'package:fireplace/src/model/chat/chat_service.dart' show ChatService; import 'package:fireplace/src/db/message_mutation.dart'; @@ -41,6 +42,7 @@ class SendNotConfirmedException implements Exception { required this.attemptedAt, required this.persisted, this.mutation, + this.quote, }); /// [SendOutcome.publishUnknown] or [SendOutcome.publishedLocalSaveFailed]. @@ -56,6 +58,7 @@ class SendNotConfirmedException implements Exception { /// False when even that failed: the caller must keep the warning in memory. final bool persisted; final MessageMutation? mutation; + final MessageQuote? quote; @override String toString() => 'Message not confirmed (${outcome.name})'; diff --git a/lib/src/model/chat/chat_service.dart b/lib/src/model/chat/chat_service.dart index 56ba62f..7f2e766 100644 --- a/lib/src/model/chat/chat_service.dart +++ b/lib/src/model/chat/chat_service.dart @@ -1,3 +1,4 @@ +import 'package:fireplace/src/db/message_quote.dart'; // ignore_for_file: prefer_initializing_formals import 'dart:async'; @@ -75,6 +76,7 @@ class ChatService { uid, device, sendText: sendText, + sendQuoted: _sender.sendQuoted, peerOf: peerOf, ); late final _journal = ReceiveJournal( @@ -174,6 +176,38 @@ class ChatService { Future sendText(String chatId, String text) => _sender.sendText(chatId, text); + Future sendReply(String chatId, String text, String targetId) => + _sender.sendReply(chatId, text, targetId); + + Future resendQuotedMessage( + String chatId, + String id, + String body, + MessageQuote quote, + ) async { + await _sender.sendQuoted(chatId, body, quote); + await _messages.remove(chatId, id); + } + + Future saveReplyLocally({ + required String chatId, + required String messageId, + required String body, + required DateTime sentAt, + required MessageQuote quote, + }) => _messages.add( + LocalMessage( + id: messageId, + chatId: chatId, + senderUid: uid, + senderDevice: device.keys.deviceId, + outgoing: true, + sentAt: sentAt, + body: body, + quote: quote, + ), + ); + Future react(String chatId, String targetId, String emoji) => _sender.sendControl( chatId, diff --git a/lib/src/model/chat/memory_pending.dart b/lib/src/model/chat/memory_pending.dart index 9619f19..5f33539 100644 --- a/lib/src/model/chat/memory_pending.dart +++ b/lib/src/model/chat/memory_pending.dart @@ -1,3 +1,4 @@ +import 'package:fireplace/src/db/message_quote.dart'; import 'package:fireplace/src/app/providers.dart'; import 'package:fireplace/src/db/local_messages.dart'; import 'package:fireplace/src/model/chat/chat_exceptions.dart'; @@ -9,8 +10,10 @@ class MemoryPending { body = e.body, chatId = e.chatId, sentAt = e.attemptedAt, - outcome = e.outcome; + outcome = e.outcome, + quote = e.quote; final String messageId; + final MessageQuote? quote; final String body, chatId; final DateTime sentAt; SendOutcome outcome; @@ -23,6 +26,7 @@ class MemoryPending { outgoing: true, sentAt: sentAt, body: body, + quote: quote, status: outcome == SendOutcome.publishedLocalSaveFailed ? MessageStatus.ok : MessageStatus.unconfirmed, diff --git a/lib/src/model/chat/message_compose_target.dart b/lib/src/model/chat/message_compose_target.dart new file mode 100644 index 0000000..9e47bb6 --- /dev/null +++ b/lib/src/model/chat/message_compose_target.dart @@ -0,0 +1,31 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/db/local_messages.dart'; + +enum MessageComposeAction { reply } + +class MessageComposeTarget { + const MessageComposeTarget(this.action, this.message); + final MessageComposeAction action; + final LocalMessage message; +} + +class MessageCompose extends Notifier { + MessageCompose(this.chatId); + final String chatId; + @override + MessageComposeTarget? build() { + ref.watch(appSessionProvider.select((s) => s.value?.uid)); + ref.watch(blockedUidsProvider); + ref.watch(hiddenChatsProvider); + ref.watch(identityAlertsProvider); + return null; + } + + void reply(LocalMessage message) => + state = MessageComposeTarget(MessageComposeAction.reply, message); + void clear() => state = null; +} + +final messageComposeProvider = NotifierProvider.autoDispose + .family(MessageCompose.new); diff --git a/lib/src/model/chat/message_content.dart b/lib/src/model/chat/message_content.dart new file mode 100644 index 0000000..5683ea4 --- /dev/null +++ b/lib/src/model/chat/message_content.dart @@ -0,0 +1,72 @@ +import 'package:fireplace/src/db/local_messages.dart'; +import 'package:fireplace/src/db/message_mutation.dart'; +import 'package:fireplace/src/db/message_quote.dart'; +import 'package:fireplace/src/model/chat/chat_exceptions.dart'; +import 'package:fireplace/src/model/chat/message_limits.dart'; + +Future<({MessageQuote? quote, MessageMutation? mutation})> +prepareMessageContent({ + required LocalMessageStore messages, + required String uid, + required String chatId, + required String text, + MessageQuote? quote, + MessageMutation? mutation, + String? replyTarget, +}) async { + if (messageTooLong(text)) throw ChatException(messageLimitError); + if (replyTarget != null) { + final original = await messages.get(chatId, replyTarget); + if (original == null || + !original.visible || + original.remotelyDeleted || + original.status != MessageStatus.ok) { + throw ChatException('This message is no longer available to reply to.'); + } + quote = MessageQuote( + targetId: original.id, + senderUid: original.senderUid, + body: clipMessage(original.body, 1024), + ); + } + if (quote != null) { + quote = MessageQuote.fromJson(quote.toJson()); + final original = await messages.get(chatId, quote.targetId); + if (original?.remotelyDeleted == true || + (original == null && await messages.has(chatId, quote.targetId))) { + quote = quote.withoutContent(); + } + } + if (mutation != null) { + final target = await messages.get(chatId, mutation.targetId); + if (target == null || + !target.visible || + target.remotelyDeleted || + target.status != MessageStatus.ok) { + throw ChatException('This message is no longer available.'); + } + if (mutation.kind == MessageMutationKind.delete) { + final age = DateTime.now().difference(target.sentAt); + if (target.senderUid != uid || + age.isNegative || + age > const Duration(hours: 24)) { + throw ChatException( + 'Only your messages from the last 24 hours can be deleted for everyone.', + ); + } + } else { + final previous = target.reactions[uid]; + mutation = MessageMutation( + kind: MessageMutationKind.reaction, + targetId: target.id, + emoji: mutation.emoji, + operation: previous?.emoji == mutation.emoji + ? ReactionOperation.clear + : ReactionOperation.set, + revision: (target.reactionClocks[uid] ?? previous?.revision ?? 0) + 1, + ); + } + mutation = MessageMutation.fromJson(mutation.toJson()); + } + return (quote: quote, mutation: mutation); +} diff --git a/lib/src/model/chat/message_receiver.dart b/lib/src/model/chat/message_receiver.dart index e67050b..c4d61ab 100644 --- a/lib/src/model/chat/message_receiver.dart +++ b/lib/src/model/chat/message_receiver.dart @@ -1,3 +1,5 @@ +import 'package:fireplace/src/db/message_quote.dart'; + import 'dart:async'; import 'dart:convert'; @@ -426,11 +428,17 @@ class MessageReceiver { var body = 'Malformed message.'; var status = MessageStatus.undecryptable; MessageMutation? mutation; + MessageQuote? quote; try { final payload = jsonDecode(utf8.decode(plain)); if (payload is Map && payload['type'] == 'text' && payload['body'] is String) { + if (payload['v'] == 3 && payload['quote'] != null) { + quote = MessageQuote.fromJson( + Map.from(payload['quote']), + ); + } body = payload['body'] as String; status = MessageStatus.ok; } else if (payload is Map && @@ -456,6 +464,7 @@ class MessageReceiver { 'body': body, 'status': status.name, if (mutation != null) 'mutation': mutation.toJson(), + if (quote != null) 'quote': quote.toJson(), 'sessions': jsonEncode([for (final x in sessions) x.toJson()]), 'opk': acceptedWith, }; diff --git a/lib/src/model/chat/message_sender.dart b/lib/src/model/chat/message_sender.dart index 167968f..94a4e82 100644 --- a/lib/src/model/chat/message_sender.dart +++ b/lib/src/model/chat/message_sender.dart @@ -1,3 +1,6 @@ +import 'package:fireplace/src/model/chat/message_content.dart'; +import 'package:fireplace/src/db/message_quote.dart'; + import 'dart:convert'; import 'package:cloud_firestore/cloud_firestore.dart'; @@ -98,6 +101,12 @@ class MessageSender { return _send(chatId, text); } + Future sendReply(String chatId, String text, String targetId) => + _send(chatId, text, replyTarget: targetId); + + Future sendQuoted(String chatId, String text, MessageQuote quote) => + _send(chatId, text, quote: quote); + Future sendControl(String chatId, MessageMutation mutation) => _send(chatId, '', mutation: mutation); @@ -105,43 +114,25 @@ class MessageSender { String chatId, String text, { MessageMutation? mutation, + MessageQuote? quote, + String? replyTarget, }) => _lock.run(() async { // A received message may have left a journal that was not fully applied. It holds a // snapshot of the session; replaying it AFTER this send would roll the ratchet back and // reuse a counter. So finish it first (if storage is still failing, the send fails // safely instead). await _journal.recoverJournals(); - if (mutation != null) { - final target = await _messages.get(chatId, mutation!.targetId); - if (target == null || - !target.visible || - target.remotelyDeleted || - target.status != MessageStatus.ok) { - throw ChatException('This message is no longer available.'); - } - if (mutation!.kind == MessageMutationKind.delete) { - final age = DateTime.now().difference(target.sentAt); - if (target.senderUid != uid || - age.isNegative || - age > const Duration(hours: 24)) { - throw ChatException( - 'Only your messages from the last 24 hours can be deleted for everyone.', - ); - } - } else { - final previous = target.reactions[uid]; - mutation = MessageMutation( - kind: MessageMutationKind.reaction, - targetId: target.id, - emoji: mutation!.emoji, - operation: previous?.emoji == mutation!.emoji - ? ReactionOperation.clear - : ReactionOperation.set, - revision: (target.reactionClocks[uid] ?? previous?.revision ?? 0) + 1, - ); - } - mutation = MessageMutation.fromJson(mutation!.toJson()); - } + final content = await prepareMessageContent( + messages: _messages, + uid: uid, + chatId: chatId, + text: text, + quote: quote, + mutation: mutation, + replyTarget: replyTarget, + ); + quote = content.quote; + mutation = content.mutation; final peerUid = peerOf(chatId); if (_safety?.isBlocked(peerUid) ?? false) { throw ChatException('You blocked this person. Unblock them to message.'); @@ -150,7 +141,7 @@ class MessageSender { final chatData = (await chatRef.get()).data() ?? const {}; final initiator = chatData['initiator'] as String?; final accepted = (chatData['accepted'] as bool?) ?? true; - if (mutation != null && !accepted) { + if ((mutation != null || quote != null) && !accepted) { throw ChatException( 'Accept this conversation before reacting or deleting for everyone.', ); @@ -182,10 +173,15 @@ class MessageSender { .where((d) => d.deviceId != device.keys.deviceId); final plain = utf8.encode( jsonEncode({ - 'v': mutation == null ? 1 : 2, + 'v': quote != null + ? 3 + : mutation == null + ? 1 + : 2, if (mutation == null) ...{ 'type': 'text', 'body': text, + if (quote != null) 'quote': quote!.toJson(), } else ...mutation!.toJson(), 'ts': DateTime.now().millisecondsSinceEpoch, @@ -343,10 +339,24 @@ class MessageSender { throw SendRefusedException(_refusedText(e.code)); } // Anything else (network lost, timeout): the message may exist. - throw await _unknownOutcome(chatId, ref.id, text, sentAt, mutation); + throw await _unknownOutcome( + chatId, + ref.id, + text, + sentAt, + mutation, + quote, + ); } catch (_) { // A non-Firebase failure while publishing (for example a timeout) is ambiguous as well. - throw await _unknownOutcome(chatId, ref.id, text, sentAt, mutation); + throw await _unknownOutcome( + chatId, + ref.id, + text, + sentAt, + mutation, + quote, + ); } // Published. The ratchets were saved before publishing, so record the message. var localSaveFailed = false; @@ -361,6 +371,7 @@ class MessageSender { sentAt: sentAt, body: text, mutation: mutation, + quote: quote, ), ); } catch (_) { @@ -384,6 +395,7 @@ class MessageSender { attemptedAt: sentAt, persisted: false, mutation: mutation, + quote: quote, ); } }); @@ -416,6 +428,7 @@ class MessageSender { String body, DateTime sentAt, MessageMutation? mutation, + MessageQuote? quote, ) async { var persisted = true; try { @@ -430,6 +443,7 @@ class MessageSender { body: body, status: MessageStatus.unconfirmed, mutation: mutation, + quote: quote, ), ); } catch (_) { @@ -443,6 +457,7 @@ class MessageSender { attemptedAt: sentAt, persisted: persisted, mutation: mutation, + quote: quote, ); } } diff --git a/lib/src/model/chat/pending_sends.dart b/lib/src/model/chat/pending_sends.dart index db2fba4..880f4ec 100644 --- a/lib/src/model/chat/pending_sends.dart +++ b/lib/src/model/chat/pending_sends.dart @@ -33,6 +33,8 @@ class PendingLocalSends body: old.body, attemptedAt: old.attemptedAt, persisted: false, + quote: old.quote, + mutation: old.mutation, ), ownerUid: ownerUid, ); diff --git a/lib/src/model/chat/receive_journal.dart b/lib/src/model/chat/receive_journal.dart index 9225c82..7add809 100644 --- a/lib/src/model/chat/receive_journal.dart +++ b/lib/src/model/chat/receive_journal.dart @@ -1,3 +1,5 @@ +import 'package:fireplace/src/db/message_quote.dart'; + import 'dart:convert'; import 'package:fireplace/src/db/secret_store.dart'; @@ -60,6 +62,9 @@ class ReceiveJournal { outgoing: false, sentAt: DateTime.fromMillisecondsSinceEpoch(j['sentAt'] as int), body: j['body'] as String, + quote: j['quote'] == null + ? null + : MessageQuote.fromJson(Map.from(j['quote'])), status: MessageStatus.values.byName(j['status'] as String), mutation: j['mutation'] == null ? null diff --git a/lib/src/model/chat/send_controller.dart b/lib/src/model/chat/send_controller.dart index f46871c..9da21db 100644 --- a/lib/src/model/chat/send_controller.dart +++ b/lib/src/model/chat/send_controller.dart @@ -17,6 +17,7 @@ class SendController extends ChangeNotifier { required this.draft, required this.draftRevision, required this.clearDraft, + this.replyTarget, required void Function(String) notice, required this.showNewest, required this.reviewIdentity, @@ -32,6 +33,7 @@ class SendController extends ChangeNotifier { final String Function() draft; final int Function() draftRevision; final void Function() clearDraft; + final String? Function()? replyTarget; late final void Function(String) _notice; final void Function() showNewest; final Future Function(String, List) reviewIdentity; @@ -101,13 +103,21 @@ class SendController extends ChangeNotifier { return; } final draftRevision = this.draftRevision(); + final reply = replyTarget?.call(); _sending = true; _sendError = null; _changed(); try { - await service.sendText(chatId(), body); + if (reply == null) { + await service.sendText(chatId(), body); + } else { + await service.sendReply(chatId(), body, reply); + } if (_disposed) return; - if (this.draftRevision() == draftRevision) clearDraft(); + if (this.draftRevision() == draftRevision && + replyTarget?.call() == reply) { + clearDraft(); + } showNewest(); } on SendNotConfirmedException catch (e) { // The message may already have been delivered, so this is NOT a plain failure: the attempted @@ -119,7 +129,10 @@ class SendController extends ChangeNotifier { _changed(); } // Edits made while the send was in flight survive: only an untouched draft is cleared. - if (this.draftRevision() == draftRevision) clearDraft(); + if (this.draftRevision() == draftRevision && + replyTarget?.call() == reply) { + clearDraft(); + } showNewest(); } on IdentityChangedException catch (e) { if (!_disposed) await reviewIdentity(e.peerUid, e.newIdentityPub); @@ -163,12 +176,7 @@ class SendController extends ChangeNotifier { mem.outcome = SendOutcome.publishedLocalSaveFailed; pendingSends().confirmed(chatId(), messageId, ownerUid: uid!); try { - await service.saveSentLocally( - chatId: chatId(), - messageId: messageId, - body: mem.body, - sentAt: mem.sentAt, - ); + await _savePending(service, mem); forgetPending(messageId); } catch (_) { // Still held in memory; the message is confirmed on the server. @@ -204,11 +212,16 @@ class SendController extends ChangeNotifier { try { final go = await confirmSendAnotherCopy(); if (go != true || _disposed) return; - await service.resendUnconfirmed( - chatId: chatId(), - messageId: messageId, - body: body, - ); + final quote = _memoryPending[messageId]?.quote; + if (quote != null) { + await service.resendQuotedMessage(chatId(), messageId, body, quote); + } else { + await service.resendUnconfirmed( + chatId: chatId(), + messageId: messageId, + body: body, + ); + } if (!_disposed) { forgetPending(messageId); _changed(); @@ -237,6 +250,22 @@ class SendController extends ChangeNotifier { } } + Future _savePending(ChatService service, MemoryPending mem) => + mem.quote == null + ? service.saveSentLocally( + chatId: chatId(), + messageId: mem.messageId, + body: mem.body, + sentAt: mem.sentAt, + ) + : service.saveReplyLocally( + chatId: chatId(), + messageId: mem.messageId, + body: mem.body, + sentAt: mem.sentAt, + quote: mem.quote!, + ); + /// Repairs local history for a message known to be on the server. No publish. Future saveOnDevice(MemoryPending mem) async { final service = chat(); @@ -244,12 +273,7 @@ class SendController extends ChangeNotifier { _messageActions[mem.messageId] = MessageRecoveryAction.saving; _changed(); try { - await service.saveSentLocally( - chatId: chatId(), - messageId: mem.messageId, - body: mem.body, - sentAt: mem.sentAt, - ); + await _savePending(service, mem); if (!_disposed) { forgetPending(mem.messageId); _changed(); diff --git a/lib/src/model/chat/send_recovery.dart b/lib/src/model/chat/send_recovery.dart index 5df4e79..3529bdf 100644 --- a/lib/src/model/chat/send_recovery.dart +++ b/lib/src/model/chat/send_recovery.dart @@ -1,3 +1,4 @@ +import 'package:fireplace/src/db/message_quote.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:fireplace/src/db/local_messages.dart'; import 'package:fireplace/src/model/keys/key_service.dart'; @@ -10,6 +11,7 @@ class SendRecovery { this.uid, this.device, { required this.sendText, + required this.sendQuoted, required this.peerOf, }); @@ -19,6 +21,7 @@ class SendRecovery { final LocalDevice device; final Future Function(String chatId, String text) sendText; final String Function(String chatId) peerOf; + final Future Function(String, String, MessageQuote) sendQuoted; /// Asks the SERVER whether an unconfirmed message exists. Only server evidence counts: a /// missing document, an offline error or a cached answer never proves it was not published. @@ -89,7 +92,12 @@ class SendRecovery { required String messageId, required String body, }) async { - await sendText(chatId, body); + final quote = (await _messages.get(chatId, messageId))?.quote; + if (quote == null) { + await sendText(chatId, body); + } else { + await sendQuoted(chatId, body, quote); + } try { await _messages.remove(chatId, messageId); } catch (_) { diff --git a/lib/src/view/chat/chat_composer.dart b/lib/src/view/chat/chat_composer.dart index c009fd8..d6ec7d0 100644 --- a/lib/src/view/chat/chat_composer.dart +++ b/lib/src/view/chat/chat_composer.dart @@ -23,7 +23,11 @@ class ChatComposer extends StatelessWidget { required this.send, required this.onUnblock, this.onInteraction, + this.contextBar, + this.focusNode, }); + final Widget? contextBar; + final FocusNode? focusNode; final double keyboardInset; final TextEditingController text; final String name; @@ -77,91 +81,111 @@ class ChatComposer extends StatelessWidget { decoration: BoxDecoration( color: FireplaceUiTokens.of(context).panel, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: - ((MediaQuery.sizeOf(context).height - keyboardInset) * - .35) - .clamp(120, 280), - ), - child: TextField( - key: Key('composer'), - controller: text, - onTap: onInteraction, - inputFormatters: [MessageInputFormatter()], - minLines: 1, - maxLines: - MediaQuery.sizeOf(context).height - keyboardInset < - 500 - ? 3 - : 6, - textCapitalization: TextCapitalization.sentences, - style: const TextStyle(fontSize: 17, height: 1.3), - decoration: InputDecoration( - filled: true, - fillColor: FireplaceUiTokens.of(context).incomingBubble, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(22), - borderSide: BorderSide.none, + ?contextBar, + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + ((MediaQuery.sizeOf(context).height - + keyboardInset) * + .35) + .clamp(120, 280), ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(22), - borderSide: BorderSide.none, + child: TextField( + key: Key('composer'), + controller: text, + focusNode: focusNode, + onTap: onInteraction, + inputFormatters: [MessageInputFormatter()], + minLines: 1, + maxLines: + MediaQuery.sizeOf(context).height - + keyboardInset < + 500 + ? 3 + : 6, + textCapitalization: TextCapitalization.sentences, + style: const TextStyle(fontSize: 17, height: 1.3), + decoration: InputDecoration( + filled: true, + fillColor: FireplaceUiTokens.of(context) + .incomingBubble, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide.none, + ), + counter: + messageCharacters(text.text) >= + (maxMessageCharacters * .9).floor() + ? Text( + '${messageCharacters(text.text)} / 16,384', + key: const Key('messageCounter'), + style: Theme.of(context) + .textTheme + .bodySmall, + ) + : null, + hintText: 'Message', + contentPadding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 11, + ), + ), + textInputAction: TextInputAction.newline, ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(22), - borderSide: BorderSide.none, + ), + ), + SizedBox(width: 4), + // Sending is part of editing; keep the keyboard ready to reply. + TextFieldTapRegion( + child: IconButton.filled( + key: Key('send'), + style: IconButton.styleFrom( + minimumSize: const Size(40, 40), + fixedSize: const Size(40, 40), + shape: const CircleBorder(), + backgroundColor: FireplaceUiTokens.of(context).accent, + foregroundColor: Theme.of(context) + .colorScheme + .onPrimary, ), - counter: - messageCharacters(text.text) >= - (maxMessageCharacters * .9).floor() - ? Text( - '${messageCharacters(text.text)} / 16,384', - key: const Key('messageCounter'), - style: Theme.of(context).textTheme.bodySmall, + onPressed: + send.sending || + text.text.trim().isEmpty || + !hasSession + ? null + : send.send, + tooltip: send.sending + ? 'Sending message' + : 'Send message', + icon: send.sending + ? SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context) + .colorScheme + .onPrimary, + ), ) - : null, - hintText: 'Message', - contentPadding: EdgeInsets.symmetric( - horizontal: 16, - vertical: 11, - ), + : Icon(Icons.arrow_upward_rounded), ), - textInputAction: TextInputAction.newline, ), - ), - ), - SizedBox(width: 4), - // Sending is part of editing; keep the keyboard ready to reply. - TextFieldTapRegion( - child: IconButton.filled( - key: Key('send'), - style: IconButton.styleFrom( - minimumSize: const Size(40, 40), - fixedSize: const Size(40, 40), - shape: const CircleBorder(), - backgroundColor: FireplaceUiTokens.of(context).accent, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - ), - onPressed: - send.sending || text.text.trim().isEmpty || !hasSession - ? null - : send.send, - tooltip: send.sending ? 'Sending message' : 'Send message', - icon: send.sending - ? SizedBox.square( - dimension: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Theme.of(context).colorScheme.onPrimary, - ), - ) - : Icon(Icons.arrow_upward_rounded), - ), + ], ), ], ), diff --git a/lib/src/view/chat/chat_screen.dart b/lib/src/view/chat/chat_screen.dart index b5fef8c..1b355e0 100644 --- a/lib/src/view/chat/chat_screen.dart +++ b/lib/src/view/chat/chat_screen.dart @@ -1,3 +1,5 @@ +import 'package:fireplace/src/view/chat/composer/message_composer_context.dart'; +import 'package:fireplace/src/model/chat/message_compose_target.dart'; import 'package:fireplace/src/view/chat/chat_unavailable.dart'; import 'package:fireplace/src/view/chat/message_selection_surface.dart'; import 'package:fireplace/src/model/chat/message_selection.dart'; @@ -22,7 +24,7 @@ import 'package:fireplace/src/model/chat/chat_service.dart'; import 'package:fireplace/src/view/safety/confirm_block.dart'; import 'package:fireplace/src/view/safety/report_dialog.dart'; import 'package:fireplace/src/widgets/app_bar.dart'; -import 'package:fireplace/src/widgets/dialog.dart'; +import 'package:fireplace/src/view/chat/confirm_another_copy.dart'; import 'package:fireplace/src/widgets/page.dart'; import 'package:fireplace/src/view/safety/verify_screen.dart'; import 'package:fireplace/src/view/chat/chat_details_screen.dart'; @@ -44,6 +46,8 @@ class _ChatScreenState extends ConsumerState { String? _ownerUid; late final RouteVisibility _routeVisibility; bool _showAll = false; + String? _locatedMessageId; + String? get _location => _locatedMessageId ?? widget.initialMessageId; late final ChatDraftController _text; late final SendController _sendController; late final TimelineScroll _timeline; @@ -62,7 +66,14 @@ class _ChatScreenState extends ConsumerState { reviewOnce: (peerUid, pub) async { final session = ref.read(appSessionProvider).value; if (session != null) { - await _reviewIdentityOnce(session, peerUid, pub); + await reviewChatIdentity( + context, + session, + peerUid, + pub, + isMounted: () => mounted, + notice: _snack, + ); } }, )..addListener(() { @@ -76,13 +87,18 @@ class _ChatScreenState extends ConsumerState { pendingSends: () => ref.read(pendingLocalSendsProvider.notifier), draft: () => _text.text, draftRevision: () => _text.revision, - clearDraft: () => _text.clear(), + replyTarget: () => + ref.read(messageComposeProvider(widget.chatId))?.message.id, + clearDraft: () { + _text.clear(); + ref.read(messageComposeProvider(widget.chatId).notifier).clear(); + }, notice: _snack, showNewest: () => _timeline.showNewestAfterOwnSend(_latest), reviewIdentity: (peerUid, pub) => _contactController.reviewIdentity(peerUid, pub), identityAlerts: () => ref.read(identityAlertsProvider).value ?? {}, - confirmSendAnotherCopy: _confirmSendAnotherCopy, + confirmSendAnotherCopy: () => confirmAnotherMessageCopy(context), )..addListener(() { _text.sendingChanged(_sendController.sending); if (mounted) setState(() {}); @@ -125,8 +141,8 @@ class _ChatScreenState extends ConsumerState { final activity = ref.read(chatActivityProvider.notifier); if (summary == null || !activity.eligible(summary)) return; final messages = ref.read(messagesProvider(widget.chatId)).value ?? []; - final index = !_showAll && widget.initialMessageId != null - ? messages.indexWhere((m) => m.id == widget.initialMessageId) + final index = !_showAll && _location != null + ? messages.indexWhere((m) => m.id == _location) : -1; activity .seen(widget.chatId, index < 0 ? messages : messages.take(index + 1)) @@ -150,7 +166,7 @@ class _ChatScreenState extends ConsumerState { } void _latest() { - if (!_showAll && widget.initialMessageId != null) { + if (!_showAll && _location != null) { setState(() => _showAll = true); } _timeline.scrollToLatest( @@ -169,50 +185,11 @@ class _ChatScreenState extends ConsumerState { super.dispose(); } - Future _confirmSendAnotherCopy() { - return showDialog( - context: context, - builder: (ctx) => UiDialog( - title: const Text('Send another copy?'), - content: const SingleChildScrollView( - child: Text( - 'The original may already have been delivered. This sends a new message.', - ), - ), - actions: [ - TextButton( - key: const Key('cancelResend'), - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - TextButton( - key: const Key('confirmResend'), - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Send another copy'), - ), - ], - ), - ); - } - void _snack(String m) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m))); } - Future _reviewIdentityOnce( - AppSession session, - String peerUid, - List newIdentityPub, - ) => reviewChatIdentity( - context, - session, - peerUid, - newIdentityPub, - isMounted: () => mounted, - notice: _snack, - ); - Future _blockPeer( AppSession session, String peerUid, @@ -290,8 +267,8 @@ class _ChatScreenState extends ConsumerState { final byTime = a.sentAt.compareTo(b.sentAt); return byTime != 0 ? byTime : a.id.compareTo(b.id); }); - final target = !_showAll && widget.initialMessageId != null - ? allMessages.indexWhere((m) => m.id == widget.initialMessageId) + final target = !_showAll && _location != null + ? allMessages.indexWhere((m) => m.id == _location) : -1; final msgs = target < 0 ? allMessages @@ -408,7 +385,7 @@ class _ChatScreenState extends ConsumerState { anchorBottom: true, child: Column( children: [ - if (!_showAll && widget.initialMessageId != null) + if (!_showAll && _location != null) SearchLocationNotice( target: target, onShowLatest: () { @@ -476,25 +453,51 @@ class _ChatScreenState extends ConsumerState { ), onRetryHistory: () => ref.invalidate(messagesProvider(widget.chatId)), + onQuote: (id) { + if (!allMessages.any( + (m) => + m.id == id && + !m.remotelyDeleted && + m.status == MessageStatus.ok, + )) { + _snack( + 'The original message is no longer available on this device.', + ); + return; + } + setState(() { + _locatedMessageId = id; + _showAll = false; + }); + _timeline.showNewestAfterOwnSend( + () => _timeline.scrollToLatest(reduceMotion: true), + ); + }, onLatest: _latest, ), ), - ChatComposer( - onInteraction: () => ref - .read(messageSelectionProvider(widget.chatId).notifier) - .clear(), - keyboardInset: keyboardInset, - text: _text, + MessageComposerContext( + chatId: widget.chatId, name: name, - blocked: blocked, - incomingRequest: incomingRequest, - waiting: waiting, - identityHeld: identityHeld, - hasSession: session != null, - contactBusy: _contactController.busy, - send: _sendController, - onUnblock: () => _contactController.contactAction( - () => session!.safety.unblock(peerUid!), + builder: (context, focus, bar) => ChatComposer( + focusNode: focus, + contextBar: bar, + onInteraction: () => ref + .read(messageSelectionProvider(widget.chatId).notifier) + .clear(), + keyboardInset: keyboardInset, + text: _text, + name: name, + blocked: blocked, + incomingRequest: incomingRequest, + waiting: waiting, + identityHeld: identityHeld, + hasSession: session != null, + contactBusy: _contactController.busy, + send: _sendController, + onUnblock: () => _contactController.contactAction( + () => session!.safety.unblock(peerUid!), + ), ), ), ], diff --git a/lib/src/view/chat/chat_timeline.dart b/lib/src/view/chat/chat_timeline.dart index af75b55..61b6953 100644 --- a/lib/src/view/chat/chat_timeline.dart +++ b/lib/src/view/chat/chat_timeline.dart @@ -18,6 +18,7 @@ class ChatTimeline extends ConsumerWidget { ChatTimeline({ super.key, required this.chatId, + this.onQuote, required this.blocked, required this.historyFailed, required this.loading, @@ -37,6 +38,7 @@ class ChatTimeline extends ConsumerWidget { final List msgs; final String name; final String chatId; + final ValueChanged? onQuote; final GlobalKey viewportKey; final ScrollController scrollController; final GlobalKey Function(String) anchorFor; @@ -138,6 +140,7 @@ class ChatTimeline extends ConsumerWidget { onToggleSelection: () => controller.toggle(message.id), actions: actionsFor(message), + onQuote: onQuote, ), ), ], diff --git a/lib/src/view/chat/composer/message_composer_context.dart b/lib/src/view/chat/composer/message_composer_context.dart new file mode 100644 index 0000000..5f75fe7 --- /dev/null +++ b/lib/src/view/chat/composer/message_composer_context.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/db/message_quote.dart'; +import 'package:fireplace/src/model/chat/message_compose_target.dart'; +import 'package:fireplace/src/model/chat/message_limits.dart'; +import 'package:fireplace/src/view/chat/widgets/message_quote_preview.dart'; + +class MessageComposerContext extends ConsumerStatefulWidget { + const MessageComposerContext({ + super.key, + required this.chatId, + required this.name, + required this.builder, + }); + final String chatId, name; + final Widget Function(BuildContext, FocusNode, Widget?) builder; + @override + ConsumerState createState() => + _MessageComposerContextState(); +} + +class _MessageComposerContextState + extends ConsumerState { + final _focus = FocusNode(); + @override + void dispose() { + _focus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final provider = messageComposeProvider(widget.chatId); + ref.listen(provider, (_, next) { + if (next != null) _focus.requestFocus(); + }); + final target = ref.watch(provider); + final uid = ref.watch(appSessionProvider.select((s) => s.value?.uid)); + final messages = ref.watch(messagesProvider(widget.chatId)).value; + final live = messages?.where((m) => m.id == target?.message.id).firstOrNull; + return widget.builder( + context, + _focus, + target == null + ? null + : Row( + key: const Key('replyComposerPreview'), + children: [ + Expanded( + child: MessageQuotePreview( + quote: MessageQuote( + targetId: target.message.id, + senderUid: target.message.senderUid, + body: clipMessage(live?.body ?? '', 1024), + unavailable: live == null || live.remotelyDeleted, + ), + author: target.message.senderUid == uid + ? 'You' + : widget.name, + ), + ), + IconButton( + key: const Key('cancelReply'), + tooltip: 'Cancel reply', + onPressed: () => ref.read(provider.notifier).clear(), + icon: const Icon(Icons.close), + ), + ], + ), + ); + } +} diff --git a/lib/src/view/chat/confirm_another_copy.dart b/lib/src/view/chat/confirm_another_copy.dart new file mode 100644 index 0000000..c7a234b --- /dev/null +++ b/lib/src/view/chat/confirm_another_copy.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:fireplace/src/widgets/dialog.dart'; + +Future confirmAnotherMessageCopy(BuildContext context) { + return showDialog( + context: context, + builder: (ctx) => UiDialog( + title: const Text('Send another copy?'), + content: const SingleChildScrollView( + child: Text( + 'The original may already have been delivered. This sends a new message.', + ), + ), + actions: [ + TextButton( + key: const Key('cancelResend'), + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + key: const Key('confirmResend'), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Send another copy'), + ), + ], + ), + ); +} diff --git a/lib/src/view/chat/message_menu.dart b/lib/src/view/chat/message_menu.dart index fbcf6be..21e96b4 100644 --- a/lib/src/view/chat/message_menu.dart +++ b/lib/src/view/chat/message_menu.dart @@ -1,3 +1,4 @@ +import 'package:fireplace/src/model/chat/message_compose_target.dart'; import 'package:fireplace/src/model/notifications/chat_activity.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -90,6 +91,17 @@ List messageMenuActions( onSelected: () => run(() => showReactionPicker(context, ref, message)), ), ], + if (canShare && message.status == MessageStatus.ok) + MessageAction( + id: 'reply', + label: 'Reply', + icon: Icons.reply_rounded, + onSelected: () => run( + () => ref + .read(messageComposeProvider(chatId()).notifier) + .reply(message), + ), + ), if (canShare) MessageAction( id: 'copy', diff --git a/lib/src/view/chat/widgets/message_bubble.dart b/lib/src/view/chat/widgets/message_bubble.dart index 1728310..177af09 100644 --- a/lib/src/view/chat/widgets/message_bubble.dart +++ b/lib/src/view/chat/widgets/message_bubble.dart @@ -1,3 +1,4 @@ +import 'package:fireplace/src/view/chat/widgets/message_quote_preview.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/semantics.dart'; @@ -23,8 +24,10 @@ class MessageBubble extends ConsumerStatefulWidget { this.selecting = false, this.selected = false, this.onToggleSelection, + this.onQuote, }); final LocalMessage message; + final ValueChanged? onQuote; final bool preview; final bool selecting, selected; final VoidCallback? onToggleSelection; @@ -40,6 +43,7 @@ class MessageBubble extends ConsumerStatefulWidget { } class _MessageBubbleState extends ConsumerState { + double _replyDrag = 0; bool _held = false; // highlighted while its menu is open bool _showAll = false; bool _focused = false; @@ -134,6 +138,9 @@ class _MessageBubbleState extends ConsumerState { @override Widget build(BuildContext context) { final message = widget.message; + final reply = widget.selecting || widget.preview + ? null + : widget.actions.where((a) => a.id == 'reply').firstOrNull; final t = FireplaceUiTokens.of(context); final colors = ref.watch(chatBubbleColorsProvider); final mine = message.outgoing; @@ -196,6 +203,18 @@ class _MessageBubbleState extends ConsumerState { child: GestureDetector( key: _actionAnchor, onTap: widget.selecting ? widget.onToggleSelection : null, + onHorizontalDragUpdate: reply == null + ? null + : (d) => setState( + () => _replyDrag = (_replyDrag + d.delta.dx).clamp(0, 72), + ), + onHorizontalDragCancel: () => setState(() => _replyDrag = 0), + onHorizontalDragEnd: reply == null + ? null + : (_) { + if (_replyDrag >= 48) reply.onSelected(); + setState(() => _replyDrag = 0); + }, // Touch: press and hold. Desktop: right click. onLongPressStart: widget.actions.isEmpty ? null @@ -208,108 +227,133 @@ class _MessageBubbleState extends ConsumerState { onLongPressEnd: (_) => _gesture?.onEnd?.call(), child: Opacity( opacity: _held ? 0 : 1, - child: Container( - key: ValueKey( - '${widget.preview ? 'heldMessage' : 'messageBubble'}-${message.id}', - ), - constraints: BoxConstraints( - maxWidth: (box.maxWidth * (widget.preview ? 1 : .8)) - .clamp(0.0, 480.0) - .toDouble(), - ), - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 8, - ), - decoration: BoxDecoration( - color: _held - ? Color.alphaBlend(const Color(0x22000000), base) - : base, - borderRadius: bubbleRadius, - ), - // Painted over the bubble, so focus never changes its size. - foregroundDecoration: _focused - ? BoxDecoration( - border: Border.all(color: fg, width: 2), - borderRadius: bubbleRadius, - ) - : null, - // The sender is announced first. The menu actions are also offered to screen readers, as - // custom actions, because a long press is hard to perform with TalkBack or VoiceOver. - child: Semantics( - label: who, - selected: widget.selecting ? widget.selected : null, - customSemanticsActions: { - for (final a - in widget.selecting - ? [] - : widget.actions) - CustomSemanticsAction(label: a.label): a.onSelected, - }, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (bad) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 6, - children: [ - Icon(Icons.lock_outline, size: 16, color: fg), - Text( - 'Unreadable message', - style: TextStyle( - color: fg, - fontWeight: FontWeight.w600, + child: Transform.translate( + offset: Offset(_replyDrag, 0), + child: Container( + key: ValueKey( + '${widget.preview ? 'heldMessage' : 'messageBubble'}-${message.id}', + ), + constraints: BoxConstraints( + maxWidth: (box.maxWidth * (widget.preview ? 1 : .8)) + .clamp(0.0, 480.0) + .toDouble(), + ), + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: _held + ? Color.alphaBlend(const Color(0x22000000), base) + : base, + borderRadius: bubbleRadius, + ), + // Painted over the bubble, so focus never changes its size. + foregroundDecoration: _focused + ? BoxDecoration( + border: Border.all(color: fg, width: 2), + borderRadius: bubbleRadius, + ) + : null, + // The sender is announced first. The menu actions are also offered to screen readers, as + // custom actions, because a long press is hard to perform with TalkBack or VoiceOver. + child: Semantics( + label: who, + selected: widget.selecting ? widget.selected : null, + customSemanticsActions: { + for (final a + in widget.selecting + ? [] + : widget.actions) + CustomSemanticsAction(label: a.label): a.onSelected, + }, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (message.quote != null && !message.remotelyDeleted) + MessageQuotePreview( + key: ValueKey('messageQuote-${message.id}'), + quote: message.quote!, + foreground: fg, + author: + message.quote!.senderUid == + ref.watch( + appSessionProvider.select( + (s) => s.value?.uid, + ), + ) + ? 'You' + : widget.senderName ?? 'Them', + onTap: widget.preview + ? null + : widget.selecting + ? widget.onToggleSelection + : () => widget.onQuote?.call( + message.quote!.targetId, + ), + ), + if (bad) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + children: [ + Icon(Icons.lock_outline, size: 16, color: fg), + Text( + 'Unreadable message', + style: TextStyle( + color: fg, + fontWeight: FontWeight.w600, + ), ), - ), - ], + ], + ), ), - ), - Align( - widthFactor: 1, - alignment: AlignmentDirectional.centerStart, - child: Text.rich( - formatted.span( - TextStyle( - color: fg, - fontSize: 17, - height: 1.3, - fontStyle: bad || message.remotelyDeleted - ? FontStyle.italic - : FontStyle.normal, + Align( + widthFactor: message.quote == null ? 1 : null, + alignment: AlignmentDirectional.centerStart, + child: Text.rich( + formatted.span( + TextStyle( + color: fg, + fontSize: 17, + height: 1.3, + fontStyle: bad || message.remotelyDeleted + ? FontStyle.italic + : FontStyle.normal, + ), ), + semanticsLabel: formatted.plain, ), - semanticsLabel: formatted.plain, ), - ), - if (oversize) - TextButton( - key: ValueKey('showAll-${message.id}'), - onPressed: widget.selecting - ? widget.onToggleSelection - : () => setState(() => _showAll = !_showAll), - child: Text( - _showAll ? 'Show less' : 'Show all', - style: TextStyle( - color: fg, - decoration: TextDecoration.underline, + if (oversize) + TextButton( + key: ValueKey('showAll-${message.id}'), + onPressed: widget.selecting + ? widget.onToggleSelection + : () => setState(() => _showAll = !_showAll), + child: Text( + _showAll ? 'Show less' : 'Show all', + style: TextStyle( + color: fg, + decoration: TextDecoration.underline, + ), ), ), + const SizedBox(height: 2), + Text( + time, + style: TextStyle( + color: mine ? fg : t.secondaryText, + fontSize: 11, + height: 1.3, + ), ), - const SizedBox(height: 2), - Text( - time, - style: TextStyle( - color: mine ? fg : t.secondaryText, - fontSize: 11, - height: 1.3, - ), - ), - ], + ], + ), ), ), ), diff --git a/lib/src/view/chat/widgets/message_quote_preview.dart b/lib/src/view/chat/widgets/message_quote_preview.dart new file mode 100644 index 0000000..3dff2c3 --- /dev/null +++ b/lib/src/view/chat/widgets/message_quote_preview.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:fireplace/src/db/message_quote.dart'; +import 'package:fireplace/src/model/chat/message_format.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; + +class MessageQuotePreview extends StatelessWidget { + const MessageQuotePreview({ + super.key, + required this.quote, + required this.author, + this.foreground, + this.onTap, + }); + final MessageQuote quote; + final String author; + final Color? foreground; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final tokens = FireplaceUiTokens.of(context); + final color = foreground ?? tokens.text; + return Semantics( + button: onTap != null, + label: 'Quoted message', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: .08), + border: Border( + left: BorderSide(color: foreground ?? tokens.accent, width: 3), + ), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + author, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + Text( + quote.unavailable + ? 'Original message unavailable' + : displayMessage(quote.body).plain, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: color, fontSize: 14, height: 1.2), + ), + ], + ), + ), + ), + ); + } +} diff --git a/scripts/render/render_screens_test.dart b/scripts/render/render_screens_test.dart index f1a064d..53a8b53 100644 --- a/scripts/render/render_screens_test.dart +++ b/scripts/render/render_screens_test.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'catalog_case.dart'; +import 'reply_edit_cases.dart'; import 'message_surface_cases.dart'; import 'dart:convert'; @@ -110,6 +111,7 @@ void main() { }); final cases = [ ...messageSurfaceCases, + ...replyEditCases, for (final mine in [false, true]) CatalogCase( mine ? 'message-actions-outgoing' : 'message-actions-incoming', diff --git a/scripts/render/reply_edit_cases.dart b/scripts/render/reply_edit_cases.dart new file mode 100644 index 0000000..c02ad69 --- /dev/null +++ b/scripts/render/reply_edit_cases.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fireplace/src/view/chat/chat_screen.dart'; + +import 'catalog_case.dart'; + +final replyEditCases = [ + for (final state in [ + 'reply-composing', + 'reply-sent', + 'reply-original', + 'reply-large', + ]) + CatalogCase( + 'messages-$state', + const ChatScreen(chatId: 'alice_fred'), + size: state.endsWith('large') + ? const Size(320, 640) + : const Size(390, 844), + textScale: state.endsWith('large') ? 2 : 1, + act: (t, f) async { + await t.drag( + find.byKey(const ValueKey('messageBubble-5')), + const Offset(90, 0), + ); + await t.pumpAndSettle(); + await t.enterText( + find.byKey(const Key('composer')), + 'Two works for me.', + ); + await t.pump(); + if (state == 'reply-composing' || state == 'reply-large') return; + await t.tap(find.byKey(const Key('send'))); + await t.pumpAndSettle(); + if (state == 'reply-original') { + await t.tap(find.byKey(const ValueKey('messageQuote-reply-1'))); + await t.pumpAndSettle(); + } + }, + ), +]; diff --git a/test/db/message_quotes_test.dart b/test/db/message_quotes_test.dart new file mode 100644 index 0000000..dd57b23 --- /dev/null +++ b/test/db/message_quotes_test.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fireplace/fireplace_services.dart'; +import 'package:fireplace/src/db/message_quote.dart'; + +import '../support/ui_fixture.dart'; + +void main() { + test('quote validation is bounded and old records still load', () { + expect( + LocalMessage.fromJson(message(id: 'a', body: 'Old text').toJson()).quote, + isNull, + ); + for (final data in [ + {'targetId': '../a', 'senderUid': 'fred', 'body': 'text'}, + {'targetId': 'a', 'senderUid': '', 'body': 'text'}, + {'targetId': 'a', 'senderUid': 'fred', 'body': 'x' * 1025}, + ]) { + expect(() => MessageQuote.fromJson(data), throwsFormatException); + } + }); + test( + 'encrypted reload preserves quote; deleting original scrubs its copies', + () async { + final dir = Directory.systemTemp.createTempSync('fireplace_quote'); + addTearDown(() => dir.deleteSync(recursive: true)); + final secrets = MemorySecretStore(); + var store = await EncryptedFileMessageStore.open( + dir: dir, + secrets: secrets, + uid: 'alice', + ); + final original = message(id: 'original', body: 'Fictional secret'); + final reply = message(id: 'reply', body: 'Response').copyWith( + quote: MessageQuote( + targetId: original.id, + senderUid: original.senderUid, + body: original.body, + ), + ); + await store.add(reply); + await store.add(original); + await store.close(); + store = await EncryptedFileMessageStore.open( + dir: dir, + secrets: secrets, + uid: 'alice', + ); + expect( + (await store.get(reply.chatId, reply.id))!.quote!.body, + original.body, + ); + await store.remove(original.chatId, original.id); + await store.add(reply); + expect( + (await store.get(reply.chatId, reply.id))!.quote!.unavailable, + isTrue, + ); + expect((await store.get(reply.chatId, reply.id))!.quote!.body, isEmpty); + await store.close(); + }, + ); +} diff --git a/test/model/chat/quoted_messages_test.dart b/test/model/chat/quoted_messages_test.dart new file mode 100644 index 0000000..7ed35f1 --- /dev/null +++ b/test/model/chat/quoted_messages_test.dart @@ -0,0 +1,57 @@ +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fireplace/src/model/chat/chat_service.dart'; + +import 'chat_service_test.dart' show Dev, addUser, eventually; + +void main() { + test( + 'replies encrypt the bounded quote for both people and linked devices', + () async { + final db = FakeFirebaseFirestore(); + await addUser(db, 'alice', 'alice'); + await addUser(db, 'bob', 'bob'); + final alice = await Dev.create(db, 'alice'), + bob = await Dev.create(db, 'bob'); + final linked = await Dev.linked(db, alice); + addTearDown(() async { + for (final d in [alice, bob, linked]) { + await d.close(); + await d.chat.close(); + } + }); + const chat = 'alice_bob'; + await db.collection('chats').doc(chat).set({ + 'members': ['alice', 'bob'], + 'accepted': true, + }); + for (final d in [alice, bob, linked]) { + d.sync(chat); + } + await bob.chat.sendText(chat, 'Fictional original ${'😀' * 1100}'); + await eventually(() async => (await alice.history(chat)).length == 1); + final original = (await alice.history(chat)).single; + await alice.chat.sendReply(chat, 'A quoted answer', original.id); + await eventually( + () async => + (await linked.history(chat)).length == 2 && + (await bob.history(chat)).length == 2, + ); + for (final d in [alice, bob, linked]) { + final reply = (await d.history(chat)).last; + expect(reply.quote!.targetId, original.id); + expect(reply.quote!.senderUid, 'bob'); + expect(reply.quote!.body.runes.length, 1024); + } + expect(db.dump(), isNot(contains('A quoted answer'))); + await bob.chat.deleteForEveryone(chat, original.id); + await eventually( + () async => (await alice.history(chat)).last.quote!.unavailable, + ); + await expectLater( + alice.chat.sendReply(chat, 'Again', original.id), + throwsA(isA()), + ); + }, + ); +} diff --git a/test/model/chat/quoted_send_failure_test.dart b/test/model/chat/quoted_send_failure_test.dart new file mode 100644 index 0000000..27aa86f --- /dev/null +++ b/test/model/chat/quoted_send_failure_test.dart @@ -0,0 +1,59 @@ +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:fireplace/fireplace_services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'durability_test.dart' show Phone, fb; + +void main() { + for (final committed in [false, true]) { + test( + 'uncertain reply retains quote and never republishes on check ($committed)', + () async { + final db = FakeFirebaseFirestore(); + for (final uid in ['alice', 'bob']) { + await db.collection('users').doc(uid).set({'username': uid}); + await db.collection('usernames').doc(uid).set({'uid': uid}); + } + final alice = await Phone.create(db, 'alice'); + final bob = await Phone.create(db, 'bob'); + addTearDown(() async { + await alice.chat.close(); + await bob.chat.close(); + }); + const chat = 'alice_bob'; + await alice.chat.startChat('bob'); + await bob.chat.acceptRequest(chat); + await alice.chat.sendText(chat, 'Original'); + final original = (await alice.messages.watch(chat).first).single; + alice.commit = (batch) async { + if (committed) await batch.commit(); + throw fb('unavailable'); + }; + SendNotConfirmedException? pending; + try { + await alice.chat.sendReply(chat, 'Answer', original.id); + } on SendNotConfirmedException catch (e) { + pending = e; + } + expect(pending!.quote!.targetId, original.id); + alice.commit = null; + await alice.chat.close(); + alice.restart(); + final outcome = await alice.chat.checkSendStatus( + chat, + pending.messageId, + ); + expect( + outcome, + committed ? SendOutcome.confirmed : SendOutcome.publishUnknown, + ); + final stored = await alice.messages.get(chat, pending.messageId); + expect(stored!.quote!.body, 'Original'); + expect( + (await db.collection('chats/$chat/messages').get()).docs.length, + committed ? 2 : 1, + ); + }, + ); + } +} diff --git a/test/support/ui_fixture.dart b/test/support/ui_fixture.dart index 654f052..3e20a4c 100644 --- a/test/support/ui_fixture.dart +++ b/test/support/ui_fixture.dart @@ -1,5 +1,9 @@ // Test/render-only fixtures. No Firebase SDK instance, real keys or platform storage. import 'dart:async'; + +import 'package:fireplace/src/db/message_quote.dart'; +import 'package:fireplace/src/model/chat/message_limits.dart'; + import 'dart:typed_data'; import 'package:fireplace/fireplace_services.dart'; @@ -76,6 +80,30 @@ class FixtureChat extends Fake implements ChatService { if (sendError != null) throw sendError!; } + final replies = <(String, String)>[]; + @override + Future sendReply(String chatId, String body, String targetId) async { + await sendText(chatId, body); + replies.add((targetId, body)); + final target = await store.get(chatId, targetId); + await store.add( + LocalMessage( + id: 'reply-${replies.length}', + chatId: chatId, + senderUid: 'alice', + senderDevice: 'example-device-alice', + outgoing: true, + sentAt: fixtureTime.add(const Duration(minutes: 1)), + body: body, + quote: MessageQuote( + targetId: targetId, + senderUid: target!.senderUid, + body: clipMessage(target.body, 1024), + ), + ), + ); + } + @override Future deleteMessageLocally(String chatId, String messageId) => store.remove(chatId, messageId); diff --git a/test/view/chat/message_menu_test.dart b/test/view/chat/message_menu_test.dart index 6f24b9e..8bcad1b 100644 --- a/test/view/chat/message_menu_test.dart +++ b/test/view/chat/message_menu_test.dart @@ -22,7 +22,7 @@ void main() { false, MessageStatus.ok, '**Hello**', - ['copy', 'forward', 'selectText', 'select', 'report'], + ['reply', 'copy', 'forward', 'selectText', 'select', 'report'], ), ( 'sent', @@ -32,7 +32,14 @@ void main() { true, MessageStatus.ok, 'Hello', - ['copy', 'forward', 'selectText', 'select', 'deleteEveryone'], + [ + 'reply', + 'copy', + 'forward', + 'selectText', + 'select', + 'deleteEveryone', + ], ), ( 'blocked', @@ -92,7 +99,7 @@ void main() { false, MessageStatus.ok, 'x' * (maxMessageCharacters + 1), - ['copy', 'selectText', 'select', 'report'], + ['reply', 'copy', 'selectText', 'select', 'report'], ), ]) { testWidgets('message menu keeps exact $label action order and labels', ( @@ -134,6 +141,7 @@ void main() { ); expect(actions!.map((a) => a.id).toList(), expected); const labels = { + 'reply': 'Reply', 'copy': 'Copy', 'forward': 'Forward', 'selectText': 'Select text', diff --git a/test/view/chat/quoted_reply_test.dart b/test/view/chat/quoted_reply_test.dart new file mode 100644 index 0000000..535ee3e --- /dev/null +++ b/test/view/chat/quoted_reply_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/providers.dart'; + +import '../../support/ui_fixture.dart'; +import 'long_press_menu_test.dart' show openChat, bubble, action; + +void main() { + testWidgets('reply preview cancels without changing the ordinary draft', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + await t.enterText(find.byKey(const Key('composer')), 'Unsent draft'); + await t.longPress(bubble('5')); + await t.pumpAndSettle(); + await t.tap(action('reply')); + await t.pumpAndSettle(); + expect(find.byKey(const Key('replyComposerPreview')), findsOneWidget); + expect( + t + .widget(find.byKey(const Key('composer'))) + .focusNode! + .hasFocus, + isTrue, + ); + await t.tap(find.byKey(const Key('cancelReply'))); + await t.pumpAndSettle(); + expect(find.byKey(const Key('replyComposerPreview')), findsNothing); + expect(find.text('Unsent draft'), findsOneWidget); + }); + testWidgets( + 'swipe right replies, sends a quote and taps back to the original', + (t) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + await t.drag(bubble('5'), const Offset(90, 0)); + await t.pumpAndSettle(); + expect(find.byKey(const Key('replyComposerPreview')), findsOneWidget); + await t.enterText(find.byKey(const Key('composer')), 'Two works for me'); + await t.pump(); + await t.tap(find.byKey(const Key('send'))); + await t.pumpAndSettle(); + expect(f.chat.replies, [('5', 'Two works for me')]); + expect(find.byKey(const Key('replyComposerPreview')), findsNothing); + final quote = find.byKey(const ValueKey('messageQuote-reply-1')); + expect(quote, findsOneWidget); + await t.tap(quote); + await t.pumpAndSettle(); + expect(bubble('5'), findsOneWidget); + expect(bubble('reply-1'), findsNothing); + }, + ); + testWidgets( + 'short or left swipe does not reply; access change clears the preview', + (t) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + await t.drag(bubble('5'), const Offset(-80, 0)); + await t.pumpAndSettle(); + expect(find.byKey(const Key('replyComposerPreview')), findsNothing); + await t.drag(bubble('5'), const Offset(90, 0)); + await t.pumpAndSettle(); + f.blocked = {'fred'}; + ProviderScope.containerOf(t.element(find.byKey(const Key('composer')))) + .invalidate(blockedUidsProvider); + await t.pumpAndSettle(); + expect(find.byKey(const Key('replyComposerPreview')), findsNothing); + }, + ); +} From 5d56c37c3e62042c79ad28b6c0c70b44c1591212 Mon Sep 17 00:00:00 2001 From: XSS3cut10n3r Date: Tue, 10 Nov 2026 04:31:51 +0000 Subject: [PATCH 3/3] Include Reply in the accessible message actions inventory Co-authored-by: Codex --- test/view/chat/message_actions_edge_cases_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/test/view/chat/message_actions_edge_cases_test.dart b/test/view/chat/message_actions_edge_cases_test.dart index 84b938c..85949b1 100644 --- a/test/view/chat/message_actions_edge_cases_test.dart +++ b/test/view/chat/message_actions_edge_cases_test.dart @@ -154,6 +154,7 @@ void main() { expect( ids.map((id) => CustomSemanticsAction.getAction(id)!.label).toSet(), { + 'Reply', 'Copy', 'Forward', 'Select text',