Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions docs/decisions/0022-quoted-replies-and-message-edits.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions lib/src/db/local_messages.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'package:fireplace/src/db/message_quote.dart';

import 'dart:async';

import 'package:fireplace/src/db/message_mutation.dart';
Expand Down Expand Up @@ -26,6 +28,7 @@ class LocalMessage {
this.locallyDeleted = false,
this.remotelyDeleted = false,
this.mutation,
this.quote,
Map<String, MessageReaction> reactions = const {},
Map<String, int> reactionClocks = const {},
}) : reactions = Map.unmodifiable(reactions),
Expand All @@ -42,12 +45,14 @@ class LocalMessage {
final bool locallyDeleted;
final bool remotelyDeleted;
final MessageMutation? mutation;
final MessageQuote? quote;
final Map<String, MessageReaction> reactions;
final Map<String, int> reactionClocks;
bool get visible => !locallyDeleted && mutation == null;

LocalMessage copyWith({
String? body,
MessageQuote? quote,
bool? outgoing,
MessageStatus? status,
bool? remotelyDeleted,
Expand All @@ -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,
);
Expand Down Expand Up @@ -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,
Expand All @@ -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<String, dynamic>.from(j['quote'])),
status: MessageStatus.values.byName(j['status']),
locallyDeleted: j['locallyDeleted'] == true,
remotelyDeleted: j['remotelyDeleted'] == true,
Expand Down
10 changes: 10 additions & 0 deletions lib/src/db/message_projection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,15 @@ Map<String, LocalMessage> 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;
}
45 changes: 45 additions & 0 deletions lib/src/db/message_quote.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> toJson() => {
'targetId': targetId,
'senderUid': senderUid,
'body': body,
if (unavailable) 'unavailable': true,
};

factory MessageQuote.fromJson(Map<String, dynamic> 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,
);
}
}
3 changes: 3 additions & 0 deletions lib/src/model/chat/chat_exceptions.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -41,6 +42,7 @@ class SendNotConfirmedException implements Exception {
required this.attemptedAt,
required this.persisted,
this.mutation,
this.quote,
});

/// [SendOutcome.publishUnknown] or [SendOutcome.publishedLocalSaveFailed].
Expand All @@ -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})';
Expand Down
34 changes: 34 additions & 0 deletions lib/src/model/chat/chat_service.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:fireplace/src/db/message_quote.dart';
// ignore_for_file: prefer_initializing_formals

import 'dart:async';
Expand Down Expand Up @@ -75,6 +76,7 @@ class ChatService {
uid,
device,
sendText: sendText,
sendQuoted: _sender.sendQuoted,
peerOf: peerOf,
);
late final _journal = ReceiveJournal(
Expand Down Expand Up @@ -174,6 +176,38 @@ class ChatService {
Future<void> sendText(String chatId, String text) =>
_sender.sendText(chatId, text);

Future<void> sendReply(String chatId, String text, String targetId) =>
_sender.sendReply(chatId, text, targetId);

Future<void> resendQuotedMessage(
String chatId,
String id,
String body,
MessageQuote quote,
) async {
await _sender.sendQuoted(chatId, body, quote);
await _messages.remove(chatId, id);
}

Future<void> 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<void> react(String chatId, String targetId, String emoji) =>
_sender.sendControl(
chatId,
Expand Down
6 changes: 5 additions & 1 deletion lib/src/model/chat/memory_pending.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -23,6 +26,7 @@ class MemoryPending {
outgoing: true,
sentAt: sentAt,
body: body,
quote: quote,
status: outcome == SendOutcome.publishedLocalSaveFailed
? MessageStatus.ok
: MessageStatus.unconfirmed,
Expand Down
31 changes: 31 additions & 0 deletions lib/src/model/chat/message_compose_target.dart
Original file line number Diff line number Diff line change
@@ -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<MessageComposeTarget?> {
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, MessageComposeTarget?, String>(MessageCompose.new);
Loading
Loading