diff --git a/docs/decisions/0052-one-colour-per-person.md b/docs/decisions/0052-one-colour-per-person.md new file mode 100644 index 0000000..469a56f --- /dev/null +++ b/docs/decisions/0052-one-colour-per-person.md @@ -0,0 +1,76 @@ +# 0052: One colour per person + +Date: 2026-11-14. Status: accepted by the owner. Changes the 1:1 message payload +(a new optional field) and replaces the two colour choices in Settings. + +## What changes + +Until now an account chose two colours on its own phone: one for its own messages +and one for everyone else's. Nobody else saw either. Group chats (0050) introduced +the other idea: each person has **one** colour, and everyone in the group sees that +person's messages in it. + +The owner's decision: that is the rule everywhere. + +- You choose **one colour**. Your messages use it. +- **Other people's messages use the soft tint of their colour**, in 1:1 chats as in + groups. Their name and avatar in a group, and the reply bar when you reply to + them, use its text shade. +- Replying to your own message uses your colour. +- Settings has one choice, *Your color*, under the heading *Make it feel like + you*. Its preview shows two bubbles in the chosen colour: **You see** (the colour + itself) and **They see** (its soft version, as your messages appear on other + people's phones). +- The second choice (the colour of other people's messages) is gone. A saved second + colour is ignored. + +## How the colour reaches the other person + +In a group it already travels as an encrypted `profile` message (0050). In a 1:1 +chat it now travels **inside each text message**: + +``` +{ "v": 1, "type": "text", "body": "...", "ts": ..., "color": "plum" } +``` + +- `color` is optional. It is the colour's name: lower-case letters, at most 24. + Anything else is dropped on receipt and the message is shown as usual. +- Only text messages (and replies) carry it. Reactions, edits and deletes do not. +- **It costs nothing extra:** no new message, no new Firestore write or read, no + function. The field is inside the ciphertext, so the server never sees it. +- Apps from before this note ignore the field: they read `type` and `body` only. +- The receiving phone remembers the latest colour for each person, in the device's + secret store (`peercolors:{uid}`), and forgets it when the account is erased + from the phone. A message from the account's own other device carries its own + colour and is not recorded as anyone else's. + +### What follows from sending it with messages + +- Someone who has not sent you a message since choosing shows in the default + colour (Stone) until they do. A changed colour shows from their next message. +- A message received only by the background notification handler does not update + the colour; the next one read with the app open does. +- **Privacy.** Your colour was known to nobody; now everyone you send a text to + learns it. It is a choice among five names and is sent only to people you are + already messaging, end-to-end encrypted. No account field, no profile field and + no server record holds it. + +## Not changed + +- The five colours and their shades. +- Group chats keep their `profile` message, which also answers a newcomer. +- Your colour is still the app's accent on your own phone. + +## Tests + +- `test/model/chat/own_color_test.dart`: the field is written only for text; a + malformed colour is dropped and the text kept; two accounts and a linked device + over the real double ratchet, with the colour learnt by the other person, not by + the sender's own second device, absent from everything the server stores, and + updated by a later message; the per-account store. +- `test/view/chat/peer_color_test.dart`, `reply_panel_test.dart`: bubbles and the + reply bar use the other person's colour, or the default before it arrives. +- `test/view/settings/`: one choice, the explanation, saving, and that an older + two-colour save reads as its first colour. + +AI assistance: Claude Code. diff --git a/docs/reviews/group-chats/dark-conversation-editing.png b/docs/reviews/group-chats/dark-conversation-editing.png index 73a66f2..55c22be 100644 Binary files a/docs/reviews/group-chats/dark-conversation-editing.png and b/docs/reviews/group-chats/dark-conversation-editing.png differ diff --git a/docs/reviews/group-chats/dark-conversation-menu.png b/docs/reviews/group-chats/dark-conversation-menu.png index 53103d4..d32169f 100644 Binary files a/docs/reviews/group-chats/dark-conversation-menu.png and b/docs/reviews/group-chats/dark-conversation-menu.png differ diff --git a/docs/reviews/group-chats/dark-conversation-replying.png b/docs/reviews/group-chats/dark-conversation-replying.png new file mode 100644 index 0000000..022056a Binary files /dev/null and b/docs/reviews/group-chats/dark-conversation-replying.png differ diff --git a/docs/reviews/group-chats/dark-conversation.png b/docs/reviews/group-chats/dark-conversation.png index b42bf47..70afc2e 100644 Binary files a/docs/reviews/group-chats/dark-conversation.png and b/docs/reviews/group-chats/dark-conversation.png differ diff --git a/docs/reviews/group-chats/dark-joined-later.png b/docs/reviews/group-chats/dark-joined-later.png index 18cc921..3629436 100644 Binary files a/docs/reviews/group-chats/dark-joined-later.png and b/docs/reviews/group-chats/dark-joined-later.png differ diff --git a/docs/reviews/group-chats/dark-list.png b/docs/reviews/group-chats/dark-list.png index ae9a7c6..c2ab3a7 100644 Binary files a/docs/reviews/group-chats/dark-list.png and b/docs/reviews/group-chats/dark-list.png differ diff --git a/docs/reviews/group-chats/dark-new-group.png b/docs/reviews/group-chats/dark-new-group.png new file mode 100644 index 0000000..4efcfc6 Binary files /dev/null and b/docs/reviews/group-chats/dark-new-group.png differ diff --git a/docs/reviews/group-chats/light-conversation-editing.png b/docs/reviews/group-chats/light-conversation-editing.png index cf39a6a..0b3779e 100644 Binary files a/docs/reviews/group-chats/light-conversation-editing.png and b/docs/reviews/group-chats/light-conversation-editing.png differ diff --git a/docs/reviews/group-chats/light-conversation-menu.png b/docs/reviews/group-chats/light-conversation-menu.png index 6bb494f..2fd4436 100644 Binary files a/docs/reviews/group-chats/light-conversation-menu.png and b/docs/reviews/group-chats/light-conversation-menu.png differ diff --git a/docs/reviews/group-chats/light-conversation-replying.png b/docs/reviews/group-chats/light-conversation-replying.png new file mode 100644 index 0000000..14a22ac Binary files /dev/null and b/docs/reviews/group-chats/light-conversation-replying.png differ diff --git a/docs/reviews/group-chats/light-conversation.png b/docs/reviews/group-chats/light-conversation.png index 6ab0216..de3a674 100644 Binary files a/docs/reviews/group-chats/light-conversation.png and b/docs/reviews/group-chats/light-conversation.png differ diff --git a/docs/reviews/group-chats/light-joined-later.png b/docs/reviews/group-chats/light-joined-later.png index ce73421..53b5e5b 100644 Binary files a/docs/reviews/group-chats/light-joined-later.png and b/docs/reviews/group-chats/light-joined-later.png differ diff --git a/docs/reviews/group-chats/light-list.png b/docs/reviews/group-chats/light-list.png index 80401b2..969d769 100644 Binary files a/docs/reviews/group-chats/light-list.png and b/docs/reviews/group-chats/light-list.png differ diff --git a/docs/reviews/group-chats/light-new-group.png b/docs/reviews/group-chats/light-new-group.png new file mode 100644 index 0000000..45d0977 Binary files /dev/null and b/docs/reviews/group-chats/light-new-group.png differ diff --git a/docs/reviews/one-colour-and-reply/README.md b/docs/reviews/one-colour-and-reply/README.md new file mode 100644 index 0000000..97674e1 --- /dev/null +++ b/docs/reviews/one-colour-and-reply/README.md @@ -0,0 +1,21 @@ +# One colour per person, the reply bar, and smaller changes: previews + +Widget renders at 390 x 844 points, in both themes, with fictional names. + +| State | Light | Dark | +|---|---|---| +| Settings, *Your color*: one choice, with "They see" and "You see" | [light-accent-ocean-chat-colors.png](light-accent-ocean-chat-colors.png) | [dark-accent-ocean-chat-colors.png](dark-accent-ocean-chat-colors.png) | +| The same with the default colour | [light-chat-colors.png](light-chat-colors.png) | [dark-chat-colors.png](dark-chat-colors.png) | +| Replying in a 1:1 chat (the other person's colour has not arrived, so the default) | [light-reply-1to1.png](light-reply-1to1.png) | [dark-reply-1to1.png](dark-reply-1to1.png) | +| Replying in a group, in that member's colour | [light-reply-group.png](light-reply-group.png) | [dark-reply-group.png](dark-reply-group.png) | +| New group: round tick boxes | [light-new-group.png](light-new-group.png) | [dark-new-group.png](dark-new-group.png) | +| Invite friends: tickets with the slight rounding of the theme boxes | [light-invite-tickets.png](light-invite-tickets.png) | [dark-invite-tickets.png](dark-invite-tickets.png) | + +Made by: + +```sh +TZ=UTC flutter test scripts/render/render_screens_test.dart --plain-name chat-colors +TZ=UTC flutter test scripts/render/render_screens_test.dart --plain-name reply-composing +TZ=UTC flutter test scripts/render/render_groups_test.dart # needs scripts/build_crypto_host.sh +TZ=UTC flutter test scripts/render/render_invites_test.dart +``` diff --git a/docs/reviews/one-colour-and-reply/dark-accent-ocean-chat-colors.png b/docs/reviews/one-colour-and-reply/dark-accent-ocean-chat-colors.png new file mode 100644 index 0000000..5d65dad Binary files /dev/null and b/docs/reviews/one-colour-and-reply/dark-accent-ocean-chat-colors.png differ diff --git a/docs/reviews/one-colour-and-reply/dark-chat-colors.png b/docs/reviews/one-colour-and-reply/dark-chat-colors.png new file mode 100644 index 0000000..49e002a Binary files /dev/null and b/docs/reviews/one-colour-and-reply/dark-chat-colors.png differ diff --git a/docs/reviews/one-colour-and-reply/dark-invite-tickets.png b/docs/reviews/one-colour-and-reply/dark-invite-tickets.png new file mode 100644 index 0000000..c106d67 Binary files /dev/null and b/docs/reviews/one-colour-and-reply/dark-invite-tickets.png differ diff --git a/docs/reviews/one-colour-and-reply/dark-new-group.png b/docs/reviews/one-colour-and-reply/dark-new-group.png new file mode 100644 index 0000000..4efcfc6 Binary files /dev/null and b/docs/reviews/one-colour-and-reply/dark-new-group.png differ diff --git a/docs/reviews/one-colour-and-reply/dark-reply-1to1.png b/docs/reviews/one-colour-and-reply/dark-reply-1to1.png new file mode 100644 index 0000000..b99e7de Binary files /dev/null and b/docs/reviews/one-colour-and-reply/dark-reply-1to1.png differ diff --git a/docs/reviews/one-colour-and-reply/dark-reply-group.png b/docs/reviews/one-colour-and-reply/dark-reply-group.png new file mode 100644 index 0000000..022056a Binary files /dev/null and b/docs/reviews/one-colour-and-reply/dark-reply-group.png differ diff --git a/docs/reviews/one-colour-and-reply/light-accent-ocean-chat-colors.png b/docs/reviews/one-colour-and-reply/light-accent-ocean-chat-colors.png new file mode 100644 index 0000000..f37cbcf Binary files /dev/null and b/docs/reviews/one-colour-and-reply/light-accent-ocean-chat-colors.png differ diff --git a/docs/reviews/one-colour-and-reply/light-chat-colors.png b/docs/reviews/one-colour-and-reply/light-chat-colors.png new file mode 100644 index 0000000..708ed50 Binary files /dev/null and b/docs/reviews/one-colour-and-reply/light-chat-colors.png differ diff --git a/docs/reviews/one-colour-and-reply/light-invite-tickets.png b/docs/reviews/one-colour-and-reply/light-invite-tickets.png new file mode 100644 index 0000000..06cf68f Binary files /dev/null and b/docs/reviews/one-colour-and-reply/light-invite-tickets.png differ diff --git a/docs/reviews/one-colour-and-reply/light-new-group.png b/docs/reviews/one-colour-and-reply/light-new-group.png new file mode 100644 index 0000000..45d0977 Binary files /dev/null and b/docs/reviews/one-colour-and-reply/light-new-group.png differ diff --git a/docs/reviews/one-colour-and-reply/light-reply-1to1.png b/docs/reviews/one-colour-and-reply/light-reply-1to1.png new file mode 100644 index 0000000..3cd2a98 Binary files /dev/null and b/docs/reviews/one-colour-and-reply/light-reply-1to1.png differ diff --git a/docs/reviews/one-colour-and-reply/light-reply-group.png b/docs/reviews/one-colour-and-reply/light-reply-group.png new file mode 100644 index 0000000..14a22ac Binary files /dev/null and b/docs/reviews/one-colour-and-reply/light-reply-group.png differ diff --git a/lib/src/app/chat_color_preference.dart b/lib/src/app/chat_color_preference.dart index e7a9ec5..2b2e117 100644 --- a/lib/src/app/chat_color_preference.dart +++ b/lib/src/app/chat_color_preference.dart @@ -42,9 +42,7 @@ class ChatAppearance extends Notifier { /// If saving fails the future fails; the choice still lasts until the app /// closes. Future outgoing(ChatBubbleColor color) => - _choose(ChatBubbleColors(outgoing: color, incoming: state.incoming)); - Future incoming(ChatBubbleColor color) => - _choose(ChatBubbleColors(outgoing: state.outgoing, incoming: color)); + _choose(ChatBubbleColors(outgoing: color)); Future reset() => _choose(const ChatBubbleColors()); Future _choose(ChatBubbleColors colors) { @@ -63,3 +61,19 @@ class ChatAppearance extends Notifier { final chatBubbleColorsProvider = NotifierProvider(ChatAppearance.new); + +/// The colour to show another person's 1:1 messages in: the one they chose, +/// once a message of theirs has carried it; the default before that. +final peerColorProvider = Provider.autoDispose.family(( + ref, + peerUid, +) { + final peers = ref.watch(appSessionProvider).value?.peerColors; + if (peers == null) return ChatBubbleColors.unknown; + final changes = peers.changes.listen((changed) { + if (changed == peerUid) ref.invalidateSelf(); + }); + ref.onDispose(changes.cancel); + return ChatBubbleColor.values.asNameMap()[peers.of(peerUid)] ?? + ChatBubbleColors.unknown; +}); diff --git a/lib/src/app/providers.dart b/lib/src/app/providers.dart index 4cdd323..462d597 100644 --- a/lib/src/app/providers.dart +++ b/lib/src/app/providers.dart @@ -11,6 +11,7 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/scheduler.dart'; +import 'package:fireplace/src/model/chat/peer_colors.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; @@ -133,6 +134,7 @@ class AppSession { required this.safety, this.pushNotifications, this.groups, + this.peerColors, LocalChatPreferences? chatPreferences, required StreamSubscription chatsSub, required this.dispose, @@ -140,6 +142,9 @@ class AppSession { }) : _chatsSub = chatsSub, chatPreferences = chatPreferences ?? LocalChatPreferences(); + /// The colours the people this account chats with chose (decision 0052). + final PeerColors? peerColors; + final String uid; final LocalChatPreferences chatPreferences; final String username; @@ -256,6 +261,11 @@ final appSessionProvider = FutureProvider((ref) async { scope.add(safety.dispose); await safety.start(); scope.checkActive(); + // One colour per person (decision 0052): this account's goes out inside + // its messages, and other people's are remembered as theirs arrive. + final peerColors = PeerColors(secrets, user.uid); + await peerColors.load(); + scope.add(peerColors.close); final chat = ChatService( db: db, uid: user.uid, @@ -266,6 +276,8 @@ final appSessionProvider = FutureProvider((ref) async { messages: store, safety: safety, lock: lock, + ownColor: () => ref.read(chatBubbleColorsProvider).outgoing.name, + onPeerColor: peerColors.note, ); scope.add(chat.close); // 8. Resolve the display username from the profile read in step 2. @@ -358,6 +370,7 @@ final appSessionProvider = FutureProvider((ref) async { } // 10. Return the session with cleanup and local-data removal. final session = AppSession( + peerColors: peerColors, uid: user.uid, username: username, device: device, diff --git a/lib/src/db/account_erasure.dart b/lib/src/db/account_erasure.dart index 0d22478..355aae2 100644 --- a/lib/src/db/account_erasure.dart +++ b/lib/src/db/account_erasure.dart @@ -33,6 +33,7 @@ Future eraseAccountSecrets(SecretStore store, String uid) async { 'msgkey', 'groupkey', 'groupcolors', + 'peercolors', 'chatprefskey', 'hidden', 'pushasked', diff --git a/lib/src/model/chat/chat_service.dart b/lib/src/model/chat/chat_service.dart index c6fcd54..6c8fbf8 100644 --- a/lib/src/model/chat/chat_service.dart +++ b/lib/src/model/chat/chat_service.dart @@ -50,6 +50,8 @@ class ChatService { required LocalMessageStore messages, SafetyService? safety, SharedLock? lock, + this.ownColor, + this.onPeerColor, @visibleForTesting Future Function(WriteBatch batch)? commitBatch, @visibleForTesting Future Function()? beforeChatListBump, }) : _db = db, @@ -69,6 +71,11 @@ class ChatService { _lock.onInvalidate(_secrets.forget); } + /// This account's own colour name, sent inside its text messages, and what + /// to do with the colour another person's message carries (decision 0052). + final String? Function()? ownColor; + final void Function(String peerUid, String color)? onPeerColor; + final FirebaseFirestore _db; final String uid; final LocalDevice device; @@ -116,7 +123,7 @@ class ChatService { alerts: _alerts, peerOf: peerOf, messageRetention: messageRetention, - ); + )..ownColor = ownColor; late final _receiver = MessageReceiver( db: _db, @@ -135,7 +142,7 @@ class ChatService { journal: _journal, recovery: _recovery, peerOf: peerOf, - ); + )..onPeerColor = onPeerColor; /// Serializes all session-state changes, also with a background isolate /// that shares this account's stores (decision 0030). diff --git a/lib/src/model/chat/message_payload.dart b/lib/src/model/chat/message_payload.dart index c547a20..2723692 100644 --- a/lib/src/model/chat/message_payload.dart +++ b/lib/src/model/chat/message_payload.dart @@ -12,11 +12,16 @@ import 'package:fireplace/src/db/message_quote.dart'; /// /// Each message also carries the sender's clock time `ts`. Changing these /// fields changes the wire format, which needs a decision record. +/// +/// A text message may carry `color`, the name of the sender's own chat colour +/// (decision 0052), so the other person's phone can show their messages in +/// it. Apps from before it ignore the field. List encodeMessagePayload({ required String text, required DateTime now, MessageQuote? quote, MessageMutation? mutation, + String? color, }) => utf8.encode( jsonEncode({ 'v': quote != null || mutation?.kind == MessageMutationKind.edit @@ -28,6 +33,7 @@ List encodeMessagePayload({ 'type': 'text', 'body': text, if (quote != null) 'quote': quote.toJson(), + 'color': ?color, } else ...mutation.toJson(), 'ts': now.millisecondsSinceEpoch, @@ -41,6 +47,9 @@ typedef DecodedPayload = ({ MessageStatus status, MessageMutation? mutation, MessageQuote? quote, + + /// The sender's own colour name, when the message carried a well-formed one. + String? color, }); DecodedPayload decodeMessagePayload(List plain) { @@ -56,6 +65,7 @@ DecodedPayload decodeMessagePayload(List plain) { quote: payload['v'] == 3 && payload['quote'] != null ? MessageQuote.fromJson(Map.from(payload['quote'])) : null, + color: _colorName(payload['color']), ); } if (payload is Map && @@ -68,6 +78,7 @@ DecodedPayload decodeMessagePayload(List plain) { status: MessageStatus.ok, mutation: MessageMutation.fromJson(Map.from(payload)), quote: null, + color: null, ); } return ( @@ -75,6 +86,7 @@ DecodedPayload decodeMessagePayload(List plain) { status: MessageStatus.ok, mutation: null, quote: null, + color: null, ); } catch (_) { return ( @@ -82,6 +94,12 @@ DecodedPayload decodeMessagePayload(List plain) { status: MessageStatus.undecryptable, mutation: null, quote: null, + color: null, ); } } + +/// A colour name is lower-case letters only; anything else is dropped, never +/// shown or stored. +String? _colorName(Object? value) => + value is String && RegExp(r'^[a-z]{1,24}$').hasMatch(value) ? value : null; diff --git a/lib/src/model/chat/message_receiver.dart b/lib/src/model/chat/message_receiver.dart index 84a2bed..bfebecd 100644 --- a/lib/src/model/chat/message_receiver.dart +++ b/lib/src/model/chat/message_receiver.dart @@ -107,6 +107,9 @@ class MessageReceiver { /// message is reported by whichever isolate received it (decision 0030). void Function(LocalMessage message)? onArrival; + /// Called with the colour a message from someone else carried. + void Function(String peerUid, String color)? onPeerColor; + /// No prekey upkeep from here (the background push handler). bool upkeep = true; @@ -475,7 +478,9 @@ class MessageReceiver { } // The message authenticated, so its key is spent whatever the payload holds. - final (:body, :status, :mutation, :quote) = decodeMessagePayload(plain); + final (:body, :status, :mutation, :quote, :color) = decodeMessagePayload( + plain, + ); // Everything below is one recoverable unit: record the outcome in the journal, then // apply it. A crash or storage failure after this point is completed on the next run // instead of leaving the history written but the ratchet (or prekey erasure) undone. @@ -494,6 +499,8 @@ class MessageReceiver { }; await _journal.writeJournal(journal); await _journal.applyJournal(journal); + // The sender's own colour, for showing their messages (decision 0052). + if (su != uid && color != null) onPeerColor?.call(su, color); if (su != uid) { onArrival?.call( LocalMessage( diff --git a/lib/src/model/chat/message_sender.dart b/lib/src/model/chat/message_sender.dart index f4115a2..c5a1d41 100644 --- a/lib/src/model/chat/message_sender.dart +++ b/lib/src/model/chat/message_sender.dart @@ -39,6 +39,10 @@ import 'package:fireplace/src/model/safety/safety_service.dart'; /// ambiguous failure keeps them and records the message as unconfirmed; it is /// never resent automatically. class MessageSender { + /// This account's own colour name, read when a message is sent, so the + /// other person's phone can show these messages in it (decision 0052). + String? Function()? ownColor; + MessageSender({ required this._db, required KeyService keys, @@ -164,6 +168,7 @@ class MessageSender { now: DateTime.now(), quote: quote, mutation: mutation, + color: mutation == null ? ownColor?.call() : null, ), chatId: chatId, ); diff --git a/lib/src/model/chat/peer_colors.dart b/lib/src/model/chat/peer_colors.dart new file mode 100644 index 0000000..ca9e482 --- /dev/null +++ b/lib/src/model/chat/peer_colors.dart @@ -0,0 +1,61 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:fireplace/src/db/secret_store.dart'; + +/// The colour each person this account chats with has chosen for themselves +/// (decision 0052). It arrives inside their 1:1 messages, encrypted like the +/// text, and is kept on this device only. +/// +/// A person who has not sent a message since choosing shows in the default +/// colour until they do. +class PeerColors { + PeerColors(this._secrets, this.uid); + final SecretStore _secrets; + final String uid; + final _colors = {}; + final _changes = StreamController.broadcast(); + + static String keyName(String uid) => 'peercolors:$uid'; + static final _name = RegExp(r'^[a-z]{1,24}$'); + + /// A colour name as it travels: lower-case letters, nothing else. + static bool valid(Object? color) => color is String && _name.hasMatch(color); + + Future load() async { + try { + final raw = await _secrets.read(keyName(uid)); + if (raw == null) return; + final json = jsonDecode(raw); + if (json is! Map) return; + for (final e in json.entries) { + if (e.key is String && valid(e.value)) { + _colors[e.key as String] = e.value as String; + } + } + } catch (_) { + // Unreadable colours must not prevent opening the app. + } + } + + /// The name of [peerUid]'s colour, or null when none has arrived. + String? of(String peerUid) => _colors[peerUid]; + + /// Fires with a person's id when their colour changes. + Stream get changes => _changes.stream; + + /// Records the colour a message from [peerUid] carried. + Future note(String peerUid, String color) async { + if (peerUid == uid || !valid(color) || _colors[peerUid] == color) return; + _colors[peerUid] = color; + if (!_changes.isClosed) _changes.add(peerUid); + try { + await _secrets.write(keyName(uid), jsonEncode(_colors)); + } catch (_) { + // It still shows until the app closes, and arrives again with the next + // message. + } + } + + Future close() => _changes.close(); +} diff --git a/lib/src/model/groups/group_service.dart b/lib/src/model/groups/group_service.dart index 7a028d7..701a826 100644 --- a/lib/src/model/groups/group_service.dart +++ b/lib/src/model/groups/group_service.dart @@ -10,6 +10,7 @@ import 'package:fireplace/src/db/group_record_file.dart'; import 'package:fireplace/src/db/local_messages.dart'; import 'package:fireplace/src/db/secret_store.dart'; import 'package:fireplace/src/db/message_mutation.dart'; +import 'package:fireplace/src/db/message_quote.dart'; import 'package:fireplace/src/model/chat/message_limits.dart'; import 'package:fireplace/src/model/chat/message_payload.dart'; import 'package:fireplace/src/model/chat/send_allowance.dart'; @@ -896,12 +897,23 @@ class GroupService { /// Encrypts and publishes one text message. The message is saved first /// (unconfirmed); a definite refusal removes it, an unknown outcome keeps /// it unconfirmed, and it is never resent automatically. - Future send(String gid, String text) { + /// + /// With [replyTo], the message quotes that one (up to 1,024 characters of + /// it), as a reply does in a 1:1 chat. + Future send(String gid, String text, {LocalMessage? replyTo}) { if (messageTooLong(text)) throw GroupActionError(messageLimitError); + final quote = replyTo == null + ? null + : MessageQuote( + targetId: replyTo.id, + senderUid: replyTo.senderUid, + body: clipMessage(replyTo.body, 1024), + ); return _publish( gid, - encodeMessagePayload(text: text, now: _now()), + encodeMessagePayload(text: text, now: _now(), quote: quote), text: text, + quote: quote, ); } @@ -977,6 +989,7 @@ class GroupService { List payload, { String? text, MessageMutation? mutation, + MessageQuote? quote, }) async { final n = _now().microsecondsSinceEpoch; final id = '$deviceId-$n'; @@ -1000,6 +1013,7 @@ class GroupService { sentAt: sentAt, body: text ?? '', status: status, + quote: quote, ); if (text != null) await _messages.add(local(MessageStatus.unconfirmed)); final ref = _db.doc('groups/$gid/events/$id'); diff --git a/lib/src/styles/chat_colors.dart b/lib/src/styles/chat_colors.dart index 5bf4d73..186d49d 100644 --- a/lib/src/styles/chat_colors.dart +++ b/lib/src/styles/chat_colors.dart @@ -63,25 +63,29 @@ enum ChatBubbleColor { brightness == Brightness.dark ? darkIncoming : lightIncoming; } +/// This account's one chat colour (decision 0052). Its own messages use it, +/// and so do other people's phones for this account's messages. Each other +/// person's messages use the soft tint of *their* colour. @immutable class ChatBubbleColors { - const ChatBubbleColors({ - this.outgoing = ChatBubbleColor.ember, - this.incoming = ChatBubbleColor.stone, - }); - final ChatBubbleColor outgoing, incoming; + const ChatBubbleColors({this.outgoing = ChatBubbleColor.ember}); + final ChatBubbleColor outgoing; - /// The saved form: the two names. Anything unknown reads as its default. - String get stored => '${outgoing.name},${incoming.name}'; - factory ChatBubbleColors.parse(String stored) { - final names = stored.split(','); - ChatBubbleColor? named(int i) => - i < names.length ? ChatBubbleColor.values.asNameMap()[names[i]] : null; - return ChatBubbleColors( - outgoing: named(0) ?? ChatBubbleColor.ember, - incoming: named(1) ?? ChatBubbleColor.stone, - ); - } + /// What someone else's messages use until their own colour has arrived. + static const ChatBubbleColor unknown = ChatBubbleColor.stone; + + /// The colour of other people's messages where no one person is meant. + ChatBubbleColor get incoming => unknown; + + /// The saved form: the colour's name. Anything unknown reads as the + /// default. An older save also named a second colour, for other people's + /// messages; it is ignored. + String get stored => outgoing.name; + factory ChatBubbleColors.parse(String stored) => ChatBubbleColors( + outgoing: + ChatBubbleColor.values.asNameMap()[stored.split(',').first] ?? + ChatBubbleColor.ember, + ); /// The tint behind a message after a jump to it (a search result, a quote, /// a pin). Between the two bubble colours and faded into [page], so it @@ -89,8 +93,14 @@ class ChatBubbleColors { /// are chosen, also when both are the same (test/styles/chat_colors_test). /// In dark mode the outgoing colour's lighter accent stands in for its /// fill, which is too close to some dark incoming fills. - Color jumpHighlight(Brightness brightness, Color page) { - final theirs = incoming.incoming(brightness); + /// + /// [other] is the colour of the other person's messages in that chat. + Color jumpHighlight( + Brightness brightness, + Color page, { + ChatBubbleColor other = unknown, + }) { + final theirs = other.incoming(brightness); final dark = brightness == Brightness.dark; final between = dark ? Color.lerp(theirs, outgoing.accentText(brightness), .9)! @@ -100,9 +110,7 @@ class ChatBubbleColors { @override bool operator ==(Object other) => - other is ChatBubbleColors && - other.outgoing == outgoing && - other.incoming == incoming; + other is ChatBubbleColors && other.outgoing == outgoing; @override - int get hashCode => Object.hash(outgoing, incoming); + int get hashCode => outgoing.hashCode; } diff --git a/lib/src/view/chat/chat_composer.dart b/lib/src/view/chat/chat_composer.dart index 48186b6..df56c8e 100644 --- a/lib/src/view/chat/chat_composer.dart +++ b/lib/src/view/chat/chat_composer.dart @@ -49,6 +49,10 @@ class ChatComposer extends StatelessWidget { final SendController? send; final Future Function() onUnblock; final VoidCallback? onInteraction; + + /// The message box is a pill: its ends are half-circles while it holds one + /// line, and stay this round as it grows. + static const double pill = 24; @override Widget build(BuildContext context) { if (blocked) { @@ -86,121 +90,127 @@ class ChatComposer extends StatelessWidget { submit ?? send!.send, }, child: Container( - padding: const EdgeInsets.fromLTRB(12, 6, 8, 6), decoration: BoxDecoration( color: FireplaceUiTokens.of(context).panel, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ + // Edge to edge: a reply's coloured tab starts at the screen's + // edge. ?contextBar, - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: - ((MediaQuery.sizeOf(context).height - - keyboardInset) * - .35) - .clamp(120, 280), - ), - child: TextField( - key: Key('composer'), - controller: text, - enabled: !editingBusy, - 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( - color: FireplaceUiTokens.of(context).accent, + Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 8, 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + ((MediaQuery.sizeOf(context).height - + keyboardInset) * + .35) + .clamp(120, 280), + ), + child: TextField( + key: Key('composer'), + controller: text, + enabled: !editingBusy, + 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(pill), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pill), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pill), + borderSide: BorderSide( + color: FireplaceUiTokens.of(context).accent, + ), + ), + 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', + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 18, + vertical: 9, ), ), - 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, ), - 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: - !canSubmit || - (editing && editingBusy) || - text.text.trim().isEmpty || - !hasSession - ? null - : submit ?? send!.send, - tooltip: editing ? 'Save edit' : 'Send message', - icon: editing && editingBusy - ? SizedBox.square( - dimension: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Theme.of(context) - .colorScheme - .onPrimary, + 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: + !canSubmit || + (editing && editingBusy) || + text.text.trim().isEmpty || + !hasSession + ? null + : submit ?? send!.send, + tooltip: editing ? 'Save edit' : 'Send message', + icon: editing && editingBusy + ? SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context) + .colorScheme + .onPrimary, + ), + ) + : Icon( + editing + ? Icons.check_rounded + : Icons.arrow_upward_rounded, ), - ) - : Icon( - editing - ? Icons.check_rounded - : Icons.arrow_upward_rounded, - ), + ), ), - ), - ], + ], + ), ), ], ), diff --git a/lib/src/view/chat/chat_timeline.dart b/lib/src/view/chat/chat_timeline.dart index c7fefd5..e79241a 100644 --- a/lib/src/view/chat/chat_timeline.dart +++ b/lib/src/view/chat/chat_timeline.dart @@ -1,4 +1,5 @@ import 'package:fireplace/src/app/chat_color_preference.dart'; +import 'package:fireplace/src/styles/chat_colors.dart'; import 'package:fireplace/src/view/chat/message_actions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -67,7 +68,15 @@ class ChatTimeline extends ConsumerWidget { final theme = Theme.of(context); final highlight = ref .watch(chatBubbleColorsProvider) - .jumpHighlight(theme.brightness, theme.scaffoldBackgroundColor); + .jumpHighlight( + theme.brightness, + theme.scaffoldBackgroundColor, + // Apart from the other person's bubbles, whatever colour they chose. + other: switch (msgs.where((m) => !m.outgoing).firstOrNull) { + final theirs? => ref.watch(peerColorProvider(theirs.senderUid)), + null => ChatBubbleColors.unknown, + }, + ); final controller = ref.read(messageSelectionProvider(chatId).notifier); return Stack( key: viewportKey, diff --git a/lib/src/view/chat/composer/message_composer_context.dart b/lib/src/view/chat/composer/message_composer_context.dart index 72301a6..83d6512 100644 --- a/lib/src/view/chat/composer/message_composer_context.dart +++ b/lib/src/view/chat/composer/message_composer_context.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/chat_color_preference.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/model/chat/message_controls.dart'; -import 'package:fireplace/src/view/chat/widgets/message_quote_preview.dart'; +import 'package:fireplace/src/view/chat/widgets/reply_panel.dart'; class ComposerContextView { const ComposerContextView({ @@ -91,6 +91,7 @@ class _MessageComposerContextState if (next != null) _focus.requestFocus(); }); final target = ref.watch(provider); + final colors = ref.watch(chatBubbleColorsProvider); 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; @@ -119,45 +120,48 @@ class _MessageComposerContextState !live.remotelyDeleted), bar: target == null ? null - : Row( - key: Key( - editing ? 'editComposerPreview' : 'replyComposerPreview', - ), + : editing + ? Row( + key: const Key('editComposerPreview'), children: [ - Expanded( - child: editing - ? const Padding( - padding: EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - child: Row( - children: [ - Icon(Icons.edit_outlined, size: 18), - SizedBox(width: 8), - Flexible(child: Text('Edit message')), - ], - ), - ) - : 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, - ), + const Expanded( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Row( + children: [ + Icon(Icons.edit_outlined, size: 18), + SizedBox(width: 8), + Flexible(child: Text('Edit message')), + ], + ), + ), ), IconButton( - key: Key(editing ? 'cancelEdit' : 'cancelReply'), - tooltip: editing ? 'Cancel edit' : 'Cancel reply', + key: const Key('cancelEdit'), + tooltip: 'Cancel edit', onPressed: () => ref.read(provider.notifier).clear(), icon: const Icon(Icons.close), ), ], + ) + : ReplyPanel( + key: const Key('replyComposerPreview'), + author: target.message.senderUid == uid ? 'You' : widget.name, + body: clipMessage(live?.body ?? '', 1024), + unavailable: live == null || live.remotelyDeleted, + // The colour of the person replied to: theirs, or this + // account's own when replying to oneself. + color: + (target.message.senderUid == uid + ? colors.outgoing + : ref.watch( + peerColorProvider(target.message.senderUid), + )) + .accentText(Theme.of(context).brightness), + onCancel: () => ref.read(provider.notifier).clear(), ), ), ); diff --git a/lib/src/view/chat/message_widgets.dart b/lib/src/view/chat/message_widgets.dart index e1ba37b..6046f4e 100644 --- a/lib/src/view/chat/message_widgets.dart +++ b/lib/src/view/chat/message_widgets.dart @@ -4,3 +4,4 @@ library; export 'package:fireplace/src/view/chat/widgets/day_separator.dart'; export 'package:fireplace/src/view/chat/widgets/message_bubble.dart'; +export 'package:fireplace/src/view/chat/widgets/reply_panel.dart'; diff --git a/lib/src/view/chat/widgets/message_bubble.dart b/lib/src/view/chat/widgets/message_bubble.dart index 9627356..15e7411 100644 --- a/lib/src/view/chat/widgets/message_bubble.dart +++ b/lib/src/view/chat/widgets/message_bubble.dart @@ -33,7 +33,12 @@ class MessageBubble extends ConsumerStatefulWidget { this.onFailedTap, this.incomingColor, this.liveOf, + this.quoteAuthor, }); + + /// The name of whoever wrote the message this one quotes, in a group. Null + /// in a 1:1 chat, where it is the other person. + final String? quoteAuthor; final LocalMessage message; /// The colour of a message from someone else, when it is not this account's @@ -122,6 +127,7 @@ class _MessageBubbleState extends ConsumerState { senderName: widget.senderName, preview: true, incomingColor: widget.incomingColor, + quoteAuthor: widget.quoteAuthor, ); } final session = previewRef.watch(appSessionProvider).value; @@ -181,6 +187,8 @@ class _MessageBubbleState extends ConsumerState { final t = FireplaceUiTokens.of(context); final colors = ref.watch(chatBubbleColorsProvider); final mine = message.outgoing; + final ChatBubbleColor theirs = + widget.incomingColor ?? ref.watch(peerColorProvider(message.senderUid)); final bad = message.status == MessageStatus.undecryptable; final fg = mine ? t.outgoingText : t.incomingText; final time = clockTime(message.sentAt, MaterialLocalizations.of(context)); @@ -199,9 +207,9 @@ class _MessageBubbleState extends ConsumerState { : widget.senderName!); final base = mine ? colors.outgoing.outgoing - : (widget.incomingColor ?? colors.incoming).incoming( - Theme.of(context).brightness, - ); + // Someone else's message: the soft tint of that person's own + // colour (decision 0052). + : theirs.incoming(Theme.of(context).brightness); final quoted = message.quote != null && !message.remotelyDeleted; // With a quote the bubble's own padding shrinks to frame the panel, so // the text keeps its usual distance from the edge with this. @@ -356,6 +364,7 @@ class _MessageBubbleState extends ConsumerState { setState(() => _showAll = !_showAll), onToggleSelection: widget.onToggleSelection, onQuote: widget.onQuote, + quoteAuthor: widget.quoteAuthor, ), ), ), diff --git a/lib/src/view/chat/widgets/message_bubble_body.dart b/lib/src/view/chat/widgets/message_bubble_body.dart index 08032cb..3155721 100644 --- a/lib/src/view/chat/widgets/message_bubble_body.dart +++ b/lib/src/view/chat/widgets/message_bubble_body.dart @@ -31,8 +31,13 @@ class MessageBubbleBody extends ConsumerWidget { required this.onToggleShowAll, this.onToggleSelection, this.onQuote, + this.quoteAuthor, }); + /// Who wrote the quoted message, where that is not simply the other person + /// in a 1:1 chat: in a group it can be any member. + final String? quoteAuthor; + final LocalMessage message; final String? senderName; final bool preview; @@ -73,7 +78,7 @@ class MessageBubbleBody extends ConsumerWidget { message.quote!.senderUid == ref.watch(appSessionProvider.select((s) => s.value?.uid)) ? 'You' - : senderName ?? 'Them', + : quoteAuthor ?? senderName ?? 'Them', onTap: preview ? null : selecting diff --git a/lib/src/view/chat/widgets/reply_panel.dart b/lib/src/view/chat/widgets/reply_panel.dart new file mode 100644 index 0000000..bd52da0 --- /dev/null +++ b/lib/src/view/chat/widgets/reply_panel.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:fireplace/src/model/chat/message_format.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; + +/// The bar above the composer while replying: a coloured tab at the leading +/// edge, the name of the person being replied to in the same colour, one line +/// of their message, and a button to cancel. +/// +/// The colour is that person's own: in a group the colour they chose, in a 1:1 +/// chat the colour their messages have on this phone, and this account's +/// primary colour when replying to oneself. +class ReplyPanel extends StatelessWidget { + const ReplyPanel({ + super.key, + required this.author, + required this.body, + required this.color, + required this.onCancel, + this.unavailable = false, + }); + final String author, body; + + /// The tab and the name. A shade that reads as text on the page. + final Color color; + final VoidCallback onCancel; + + /// The original has gone (deleted) since the reply was started. + final bool unavailable; + + static const double tab = 4; + + @override + Widget build(BuildContext context) { + final t = FireplaceUiTokens.of(context); + return Semantics( + container: true, + label: 'Replying to $author', + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // From the screen's edge, the full height of the bar. + ColoredBox( + key: const Key('replyPanelTab'), + color: color, + child: const SizedBox(width: tab), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 4, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + author, + key: const Key('replyPanelAuthor'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: 16, + height: 1.25, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + unavailable + ? 'Original message unavailable' + : displayMessage(body).plain, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: t.text, + fontSize: 15, + height: 1.3, + ), + ), + ], + ), + ), + ), + Center( + child: IconButton( + key: const Key('cancelReply'), + tooltip: 'Cancel reply', + onPressed: onCancel, + icon: const Icon(Icons.cancel_outlined), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/view/groups/contact_picker.dart b/lib/src/view/groups/contact_picker.dart index c9b976e..465b330 100644 --- a/lib/src/view/groups/contact_picker.dart +++ b/lib/src/view/groups/contact_picker.dart @@ -31,6 +31,7 @@ class ContactPicker extends ConsumerWidget { return CheckboxListTile( key: Key('pick_$uid'), value: ticked, + checkboxShape: const CircleBorder(), secondary: PersonAvatar(name: name, size: 36), title: Text('@$name'), onChanged: !ticked && selected.length >= limit diff --git a/lib/src/view/groups/group_chat_screen.dart b/lib/src/view/groups/group_chat_screen.dart index 95c7537..530c0e4 100644 --- a/lib/src/view/groups/group_chat_screen.dart +++ b/lib/src/view/groups/group_chat_screen.dart @@ -1,11 +1,14 @@ import 'dart:async'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/chat_color_preference.dart'; import 'package:fireplace/src/app/group_providers.dart'; import 'package:fireplace/src/app/providers.dart'; import 'package:fireplace/src/db/local_messages.dart'; +import 'package:fireplace/src/model/chat/message_limits.dart'; import 'package:fireplace/src/model/groups/group_models.dart'; import 'package:fireplace/src/styles/design_tokens.dart'; import 'package:fireplace/src/view/chat/chat_composer.dart'; @@ -37,6 +40,7 @@ class _GroupChatScreenState extends ConsumerState { final _edit = TextEditingController(); final _focus = FocusNode(); LocalMessage? _editing; + LocalMessage? _replyTo; bool _busy = false; String? _error; @@ -96,13 +100,25 @@ class _GroupChatScreenState extends ConsumerState { final service = ref.read(groupServiceProvider); final text = _text.text.trim(); if (service == null || text.isEmpty) return; + final replyTo = _replyTo; _text.clear(); - await _run(() => service.send(widget.gid, text)); + setState(() => _replyTo = null); + await _run(() => service.send(widget.gid, text, replyTo: replyTo)); + } + + void _startReply(LocalMessage message) { + setState(() { + _replyTo = message; + _editing = null; + _error = null; + }); + _focus.requestFocus(); } void _startEdit(LocalMessage message) { setState(() { _editing = message; + _replyTo = null; _error = null; }); _edit.value = TextEditingValue( @@ -189,6 +205,13 @@ class _GroupChatScreenState extends ConsumerState { onEmoji: react, onSelected: () {}, ), + if (sent) + MessageAction( + id: 'reply', + label: 'Reply', + icon: CupertinoIcons.arrowshape_turn_up_left, + onSelected: () => _startReply(message), + ), if (message.outgoing && sent) MessageAction( id: 'edit', @@ -303,6 +326,12 @@ class _GroupChatScreenState extends ConsumerState { .length; final joinedLater = service.joinedLater(widget.gid); final editing = _editing != null; + // The message being replied to, as it is now: it may have been edited or + // deleted since. + final replyTo = _replyTo == null + ? null + : rows.where((m) => m.id == _replyTo!.id).firstOrNull; + final brightness = Theme.of(context).brightness; bool sameRun(LocalMessage a, LocalMessage? b) => b != null && b.status != MessageStatus.notice && @@ -453,6 +482,32 @@ class _GroupChatScreenState extends ConsumerState { ), ], ) + : _replyTo != null + ? Consumer( + builder: (context, barRef, _) => ReplyPanel( + key: const Key('replyComposerPreview'), + author: _replyTo!.outgoing + ? 'You' + : barRef + .watch( + peerUsernameProvider(_replyTo!.senderUid), + ) + .value ?? + '…', + body: clipMessage(replyTo?.body ?? '', 1024), + unavailable: replyTo == null, + // The colour of the person replied to: their own in + // this group, or this account's primary colour. + color: + (_replyTo!.outgoing + ? barRef + .watch(chatBubbleColorsProvider) + .outgoing + : memberColor(colors, _replyTo!.senderUid)) + .accentText(brightness), + onCancel: () => setState(() => _replyTo = null), + ), + ) : null, ), ], diff --git a/lib/src/view/groups/group_message_row.dart b/lib/src/view/groups/group_message_row.dart index f6b9d54..298501e 100644 --- a/lib/src/view/groups/group_message_row.dart +++ b/lib/src/view/groups/group_message_row.dart @@ -50,6 +50,11 @@ class GroupMessageRow extends ConsumerWidget { final name = mine ? 'You' : ref.watch(peerUsernameProvider(message.senderUid)).value ?? '…'; + // A quoted message can be anyone's in the group, not just the sender's. + final quoted = message.quote?.senderUid; + final quoteAuthor = quoted == null + ? null + : ref.watch(peerUsernameProvider(quoted)).value ?? '…'; final reserve = message.reactions.values.any((reaction) => reaction.emoji != null) ? ReactionChipLayout.reserve * MediaQuery.textScalerOf(context).scale(1) @@ -63,6 +68,7 @@ class GroupMessageRow extends ConsumerWidget { senderName: name, actions: actions, incomingColor: mine ? null : color, + quoteAuthor: quoteAuthor, liveOf: (previewRef) => previewRef .watch(groupMessagesProvider(gid)) .value diff --git a/lib/src/view/invites/invite_ticket.dart b/lib/src/view/invites/invite_ticket.dart index 699cb41..0b0fb5d 100644 --- a/lib/src/view/invites/invite_ticket.dart +++ b/lib/src/view/invites/invite_ticket.dart @@ -137,7 +137,8 @@ class InviteTicket extends StatelessWidget { padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: t.panel, - borderRadius: BorderRadius.circular(16), + // The same slight rounding as the theme boxes in Settings. + borderRadius: BorderRadius.circular(6), border: Border.all(color: theme.colorScheme.outline, width: 1.25), ), child: body, diff --git a/lib/src/view/settings/chat_appearance.dart b/lib/src/view/settings/chat_appearance.dart index 66aa36b..dd7d96b 100644 --- a/lib/src/view/settings/chat_appearance.dart +++ b/lib/src/view/settings/chat_appearance.dart @@ -21,7 +21,7 @@ class ChatAppearanceScreen extends ConsumerWidget { ..showSnackBar( const SnackBar( content: Text( - 'Could not save your colors. They will last until the app closes.', + 'Could not save your color. It will last until the app closes.', ), ), ); @@ -34,7 +34,7 @@ class ChatAppearanceScreen extends ConsumerWidget { final t = FireplaceUiTokens.of(context); final brightness = Theme.of(context).brightness; return Scaffold( - appBar: UiAppBar(context: context, title: const Text('Chat colors')), + appBar: UiAppBar(context: context, title: const Text('Your color')), body: UiPageScroll( children: [ Text( @@ -43,7 +43,9 @@ class ChatAppearanceScreen extends ConsumerWidget { ), const SizedBox(height: 8), const Text( - 'Choose colors for your messages and the ones you receive. Your color is also used for avatars, buttons and outlines.', + key: Key('colorExplanation'), + 'Choose colors for your messages in all chats. Your color is also ' + 'used for avatars, buttons, and assets within the app.', ), const SizedBox(height: 24), Card( @@ -51,15 +53,19 @@ class ChatAppearanceScreen extends ConsumerWidget { padding: const EdgeInsets.all(20), child: Column( children: [ + // What your messages look like on other people's phones: + // the soft version of the colour you chose. _Preview( - label: 'Their messages', - color: colors.incoming.incoming(brightness), + key: const Key('theySeePreview'), + label: 'They see', + color: colors.outgoing.incoming(brightness), text: t.incomingText, outgoing: false, ), const SizedBox(height: 12), _Preview( - label: 'Your messages', + key: const Key('youSeePreview'), + label: 'You see', color: colors.outgoing.outgoing, text: Colors.white, outgoing: true, @@ -70,7 +76,7 @@ class ChatAppearanceScreen extends ConsumerWidget { ), const SizedBox(height: 28), _ColorChoices( - title: 'Your messages', + title: 'Your color', selected: colors.outgoing, prefix: 'outgoing', swatch: (c) => c.outgoing, @@ -80,21 +86,10 @@ class ChatAppearanceScreen extends ConsumerWidget { ref.read(chatBubbleColorsProvider.notifier).outgoing(color), ), ), - const SizedBox(height: 24), - _ColorChoices( - title: 'Their messages', - selected: colors.incoming, - prefix: 'incoming', - swatch: (c) => c.incoming(brightness), - text: t.incomingText, - onSelected: (color) => _save( - context, - ref.read(chatBubbleColorsProvider.notifier).incoming(color), - ), - ), const SizedBox(height: 16), Text( - 'Applies to all chats. Your choice is kept on this device.', + 'Applies to all chats. Your choice is kept on this device. It is ' + 'also encrypted in your messages, the server never sees it.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), @@ -105,7 +100,7 @@ class ChatAppearanceScreen extends ConsumerWidget { context, ref.read(chatBubbleColorsProvider.notifier).reset(), ), - child: const Text('Reset colors'), + child: const Text('Reset color'), ), ), ], @@ -225,6 +220,7 @@ class ColorBlock extends StatelessWidget { class _Preview extends StatelessWidget { const _Preview({ + super.key, required this.label, required this.color, required this.text, diff --git a/lib/src/view/settings/settings_screen.dart b/lib/src/view/settings/settings_screen.dart index 9a543d3..b30653c 100644 --- a/lib/src/view/settings/settings_screen.dart +++ b/lib/src/view/settings/settings_screen.dart @@ -166,12 +166,8 @@ class _SettingsScreenState extends ConsumerState { ListTile( key: const Key('chatColors'), leading: const Icon(Icons.palette_outlined), - title: const Text('Chat colors'), - // Your colour first, then theirs: "Ember & Stone". - subtitle: Text( - '${bubbleColors.outgoing.label} & ' - '${bubbleColors.incoming.label}', - ), + title: const Text('Your color'), + subtitle: Text(bubbleColors.outgoing.label), trailing: const Icon(Icons.chevron_right), onTap: _busy ? null diff --git a/scripts/render/motion_frames_test.dart b/scripts/render/motion_frames_test.dart index d3e0662..4e98c7a 100644 --- a/scripts/render/motion_frames_test.dart +++ b/scripts/render/motion_frames_test.dart @@ -229,15 +229,8 @@ void main() { const pairs = { 'ember-stone': ChatBubbleColors(), - 'ember-ember': ChatBubbleColors(incoming: ChatBubbleColor.ember), - 'forest-forest': ChatBubbleColors( - outgoing: ChatBubbleColor.forest, - incoming: ChatBubbleColor.forest, - ), - 'ocean-ocean': ChatBubbleColors( - outgoing: ChatBubbleColor.ocean, - incoming: ChatBubbleColor.ocean, - ), + 'forest-stone': ChatBubbleColors(outgoing: ChatBubbleColor.forest), + 'ocean-stone': ChatBubbleColors(outgoing: ChatBubbleColor.ocean), }; for (final brightness in Brightness.values) { for (final pair in pairs.entries) { diff --git a/scripts/render/render_groups_test.dart b/scripts/render/render_groups_test.dart index 66b2186..632883b 100644 --- a/scripts/render/render_groups_test.dart +++ b/scripts/render/render_groups_test.dart @@ -19,6 +19,7 @@ import 'package:fireplace/src/styles/theme.dart'; import 'package:fireplace/src/view/chat_list/chat_list_screen.dart'; import 'package:fireplace/src/view/groups/group_chat_screen.dart'; import 'package:fireplace/src/view/groups/group_info_screen.dart'; +import 'package:fireplace/src/view/groups/new_group_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -131,7 +132,11 @@ void main() { overrides: [ ...fixture.overrides, groupServiceProvider.overrideWithValue(alice.service), - groupContactsProvider.overrideWithValue(const ['bob_jones']), + groupContactsProvider.overrideWithValue(const [ + 'bob_jones', + 'carol', + 'dana', + ]), ], child: RepaintBoundary( key: boundary, @@ -199,6 +204,28 @@ void main() { } }, ); + await render( + 'conversation-replying', + GroupChatScreen(gid: mine), + act: () async { + await t.longPress(find.text('Thursday works for me')); + await t.pump(const Duration(milliseconds: 600)); + await t.tap(find.byKey(const ValueKey('messageAction-reply'))); + for (var i = 0; i < 10; i++) { + await t.pump(const Duration(milliseconds: 100)); + } + }, + ); + await render( + 'new-group', + const NewGroupScreen(), + act: () async { + await t.enterText(find.byKey(const Key('groupName')), 'Hill walkers'); + await t.pump(const Duration(milliseconds: 100)); + await t.tap(find.byKey(const Key('pick_bob_jones'))); + await t.pump(const Duration(milliseconds: 300)); + }, + ); await render('joined-later', GroupChatScreen(gid: theirs)); await render('info', GroupInfoScreen(gid: mine)); diff --git a/scripts/render/render_invites_test.dart b/scripts/render/render_invites_test.dart new file mode 100644 index 0000000..d90356a --- /dev/null +++ b/scripts/render/render_invites_test.dart @@ -0,0 +1,90 @@ +// Renders the Invite friends page into build/renders/invites/, for review and +// for pull requests: +// +// TZ=UTC flutter test scripts/render/render_invites_test.dart +// +// Three rows as a new account sees them: one ready, two counting down. A fake +// Firestore and a fixed clock, so the images are the same on every run. +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:fireplace/src/app/invite_providers.dart'; +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/styles/theme.dart'; +import 'package:fireplace/src/view/invites/invite_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../test/support/ui_fixture.dart'; +import 'render_screens_test.dart' show loadFonts; + +void main() { + final created = DateTime.utc(2026, 11, 14, 12); + + for (final brightness in Brightness.values) { + testWidgets('invite tickets, ${brightness.name}', (t) async { + await t.runAsync(loadFonts); + final previousShadows = debugDisableShadows; + debugDisableShadows = false; + t.view.physicalSize = const Size(390, 844); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + final db = FakeFirebaseFirestore(); + final fixture = UiFixture(); + await fixture.seed(); + addTearDown(fixture.session.close); + await t.runAsync( + () => db.doc('users/alice').set({ + 'username': 'alice', + 'createdAt': Timestamp.fromDate(created), + }), + ); + final boundary = GlobalKey(); + final theme = fireplaceTheme(brightness); + await t.pumpWidget( + ProviderScope( + overrides: [ + ...fixture.overrides, + firestoreProvider.overrideWithValue(db), + // An hour after sign-up: the first invite is ready. + inviteClockProvider.overrideWithValue( + () => created.add(const Duration(hours: 1)), + ), + ], + child: RepaintBoundary( + key: boundary, + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: theme.copyWith( + textTheme: theme.textTheme.apply(fontFamily: 'Roboto'), + ), + home: const InviteScreen(), + ), + ), + ), + ); + await settleUi(t); + final renderer = + boundary.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final png = await t.runAsync(() async { + final image = await renderer.toImage(pixelRatio: 2); + try { + return (await image.toByteData(format: ui.ImageByteFormat.png))! + .buffer + .asUint8List(); + } finally { + image.dispose(); + } + }); + File('build/renders/invites/${brightness.name}-invite-tickets.png') + ..createSync(recursive: true) + ..writeAsBytesSync(png!); + // The test binding checks this is back before the body ends. + debugDisableShadows = previousShadows; + }); + } +} diff --git a/test/model/chat/chat_service_test.dart b/test/model/chat/chat_service_test.dart index 8ce5c9e..ee2bf82 100644 --- a/test/model/chat/chat_service_test.dart +++ b/test/model/chat/chat_service_test.dart @@ -20,6 +20,11 @@ class Dev { late ChatService chat; final subs = >[]; + /// This account's own colour, sent inside its messages when set, and the + /// colours other people's messages carried (decision 0052). + String? color; + final peerColors = {}; + static Future create(FakeFirebaseFirestore db, String uid) async { final secrets = MemorySecretStore(); final d = Dev( @@ -68,6 +73,8 @@ class Dev { prekeys: PreKeyService(db, secrets), secrets: secrets, messages: messages, + ownColor: () => color, + onPeerColor: (peer, c) => peerColors[peer] = c, ); await PreKeyService(db, secrets).maintain(uid, ld); } diff --git a/test/model/chat/own_color_test.dart b/test/model/chat/own_color_test.dart new file mode 100644 index 0000000..c70fe16 --- /dev/null +++ b/test/model/chat/own_color_test.dart @@ -0,0 +1,140 @@ +// One colour per person (decision 0052): a text message carries its sender's +// colour, encrypted with the text, and the other person's phone remembers it. +import 'dart:convert'; + +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:fireplace/fireplace_services.dart'; +import 'package:fireplace/src/db/message_mutation.dart'; +import 'package:fireplace/src/model/chat/message_payload.dart'; +import 'package:fireplace/src/model/chat/peer_colors.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'chat_service_test.dart' show Dev, addUser, eventually; + +void main() { + final now = DateTime.utc(2026, 11, 14); + + test('a text message can carry the sender\'s colour; others cannot', () { + final plain = encodeMessagePayload(text: 'hi', now: now, color: 'plum'); + expect((jsonDecode(utf8.decode(plain)) as Map)['color'], 'plum'); + expect(decodeMessagePayload(plain).color, 'plum'); + expect(decodeMessagePayload(plain).body, 'hi'); + // Without one, the payload is exactly what it was before. + final bare = encodeMessagePayload(text: 'hi', now: now); + expect((jsonDecode(utf8.decode(bare)) as Map).containsKey('color'), false); + expect(decodeMessagePayload(bare).color, isNull); + // A reaction says nothing about colour. + final reaction = encodeMessagePayload( + text: '', + now: now, + color: 'plum', + mutation: MessageMutation( + kind: MessageMutationKind.reaction, + targetId: 'm1', + emoji: '👍', + operation: ReactionOperation.set, + revision: 1, + ), + ); + expect( + (jsonDecode(utf8.decode(reaction)) as Map).containsKey('color'), + false, + ); + }); + + test( + 'a colour that is not a plain name is dropped, and the text is kept', + () { + for (final bad in [ + 'Plum', + 'plum;drop', + '', + 'x' * 25, + '#ff0000', + 7, + ['plum'], + ]) { + final plain = utf8.encode( + jsonEncode({'v': 1, 'type': 'text', 'body': 'hi', 'color': bad}), + ); + final read = decodeMessagePayload(plain); + expect(read.color, isNull, reason: '$bad'); + expect(read.body, 'hi', reason: '$bad'); + expect(read.status, MessageStatus.ok, reason: '$bad'); + } + }, + ); + + test('the other person\'s phone learns the colour from a message, and the ' + 'server never sees it', () 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); + } + alice.color = 'plum'; + await alice.chat.sendText(chat, 'Hello Bob'); + await eventually(() async => (await bob.history(chat)).length == 1); + await eventually(() async => bob.peerColors['alice'] == 'plum'); + // Alice's other phone gets the copy but notes nothing: it is her colour. + await eventually(() async => (await linked.history(chat)).length == 1); + expect(linked.peerColors, isEmpty); + expect(db.dump(), isNot(contains('plum'))); + + // Bob has chosen nothing to send: Alice learns nothing about him. + await bob.chat.sendText(chat, 'Hello Alice'); + await eventually(() async => (await alice.history(chat)).length == 2); + expect(alice.peerColors, isEmpty); + + // A change travels with the next message. + alice.color = 'forest'; + await alice.chat.sendText(chat, 'New colour'); + await eventually(() async => bob.peerColors['alice'] == 'forest'); + }); + + test('colours are remembered on the device, per account', () async { + final secrets = MemorySecretStore(); + final colors = PeerColors(secrets, 'alice'); + await colors.load(); + final seen = []; + final sub = colors.changes.listen(seen.add); + await colors.note('bob', 'plum'); + await colors.note('bob', 'plum'); // unchanged: nothing to announce + await colors.note('alice', 'ember'); // her own: ignored + await colors.note('carol', 'Not A Name'); + await Future.delayed(Duration.zero); + expect(colors.of('bob'), 'plum'); + expect(colors.of('alice'), isNull); + expect(colors.of('carol'), isNull); + expect(seen, ['bob']); + await sub.cancel(); + + final again = PeerColors(secrets, 'alice'); + await again.load(); + expect(again.of('bob'), 'plum'); + // Another account on this phone has its own. + final other = PeerColors(secrets, 'dana'); + await other.load(); + expect(other.of('bob'), isNull); + // A damaged record reads as empty. + await secrets.write(PeerColors.keyName('alice'), '{not json'); + final damaged = PeerColors(secrets, 'alice'); + await damaged.load(); + expect(damaged.of('bob'), isNull); + }); +} diff --git a/test/model/groups/group_service_test.dart b/test/model/groups/group_service_test.dart index b4fc505..6efc479 100644 --- a/test/model/groups/group_service_test.dart +++ b/test/model/groups/group_service_test.dart @@ -213,6 +213,30 @@ void main() { }, ); + test('a reply quotes the message it answers, whoever wrote it', () async { + final gid = await bookClub(); + await alice.service.send(gid, 'Thursday at 7?'); + await until( + () async => (await texts(bob, gid)).contains('Thursday at 7?'), + what: 'delivered', + ); + final original = (await bob.messages.watch(groupChatId(gid)).first) + .firstWhere((m) => m.body == 'Thursday at 7?'); + await bob.service.send(gid, 'Works for me', replyTo: original); + await until( + () async => (await texts(alice, gid)).contains('Works for me'), + what: 'the reply', + ); + for (final p in [alice, bob]) { + final reply = (await p.messages.watch(groupChatId(gid)).first).firstWhere( + (m) => m.body == 'Works for me', + ); + expect(reply.quote?.targetId, original.id); + expect(reply.quote?.senderUid, 'alice'); + expect(reply.quote?.body, 'Thursday at 7?'); + } + }); + test('a member deletes a message from their own phone only', () async { final gid = await bookClub(); await alice.service.send(gid, 'only mine to hide'); diff --git a/test/styles/chat_colors_test.dart b/test/styles/chat_colors_test.dart index fbe4e3b..132e647 100644 --- a/test/styles/chat_colors_test.dart +++ b/test/styles/chat_colors_test.dart @@ -39,11 +39,12 @@ void main() { for (final outgoing in ChatBubbleColor.values) { for (final incoming in ChatBubbleColor.values) { final pair = '${outgoing.label} & ${incoming.label}'; - final colors = ChatBubbleColors( - outgoing: outgoing, - incoming: incoming, + final colors = ChatBubbleColors(outgoing: outgoing); + final highlight = colors.jumpHighlight( + brightness, + page, + other: incoming, ); - final highlight = colors.jumpHighlight(brightness, page); expect(highlight.a, 1, reason: pair); expect( _difference(highlight, outgoing.outgoing), @@ -67,18 +68,20 @@ void main() { test('the highlight follows the chosen colours', () { final page = fireplaceTheme(Brightness.light).scaffoldBackgroundColor; - const ember = ChatBubbleColors( - outgoing: ChatBubbleColor.ember, - incoming: ChatBubbleColor.ember, - ); - const forest = ChatBubbleColors( - outgoing: ChatBubbleColor.forest, - incoming: ChatBubbleColor.forest, - ); + const ember = ChatBubbleColors(outgoing: ChatBubbleColor.ember); + const forest = ChatBubbleColors(outgoing: ChatBubbleColor.forest); expect( _difference( - ember.jumpHighlight(Brightness.light, page), - forest.jumpHighlight(Brightness.light, page), + ember.jumpHighlight( + Brightness.light, + page, + other: ChatBubbleColor.ember, + ), + forest.jumpHighlight( + Brightness.light, + page, + other: ChatBubbleColor.forest, + ), ), greaterThan(10), ); diff --git a/test/view/chat/composer_presentation_test.dart b/test/view/chat/composer_presentation_test.dart index 412b2cc..84a4026 100644 --- a/test/view/chat/composer_presentation_test.dart +++ b/test/view/chat/composer_presentation_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fireplace/src/styles/design_tokens.dart'; +import 'package:fireplace/src/view/chat/chat_composer.dart'; import '../../support/ui_fixture.dart'; import 'long_press_menu_test.dart' show openChat; @@ -20,7 +21,7 @@ void main() { expect(field.decoration!.filled, isTrue); expect( (field.decoration!.enabledBorder as OutlineInputBorder).borderRadius, - BorderRadius.circular(22), + BorderRadius.circular(ChatComposer.pill), ); // Typing shows a thin outline in the accent colour; at rest there is none. expect( @@ -28,7 +29,7 @@ void main() { BorderSide.none, ); final focused = field.decoration!.focusedBorder as OutlineInputBorder; - expect(focused.borderRadius, BorderRadius.circular(22)); + expect(focused.borderRadius, BorderRadius.circular(ChatComposer.pill)); expect( focused.borderSide, BorderSide(color: FireplaceUiTokens.of(t.element(input)).accent), diff --git a/test/view/chat/long_press_menu_test.dart b/test/view/chat/long_press_menu_test.dart index 7fdb651..d56f466 100644 --- a/test/view/chat/long_press_menu_test.dart +++ b/test/view/chat/long_press_menu_test.dart @@ -6,16 +6,21 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart' show Override; import 'package:flutter_test/flutter_test.dart'; import '../../support/ui_fixture.dart'; class Safety extends FixtureSafety {} -Future openChat(WidgetTester t, UiFixture f) async { +Future openChat( + WidgetTester t, + UiFixture f, { + List overrides = const [], +}) async { await t.pumpWidget( ProviderScope( - overrides: f.overrides, + overrides: [...f.overrides, ...overrides], child: MaterialApp( theme: fireplaceTheme(Brightness.light), home: const ChatScreen(chatId: 'alice_fred'), diff --git a/test/view/chat/peer_color_test.dart b/test/view/chat/peer_color_test.dart new file mode 100644 index 0000000..0773e31 --- /dev/null +++ b/test/view/chat/peer_color_test.dart @@ -0,0 +1,55 @@ +// In a 1:1 chat the other person's messages, and a reply to them, use the +// colour that person chose (decision 0052); before it has arrived, the default. +import 'package:fireplace/src/app/chat_color_preference.dart'; +import 'package:fireplace/src/styles/chat_colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/ui_fixture.dart'; +import 'long_press_menu_test.dart' show openChat, bubble, action; + +void main() { + Color fill(WidgetTester t, String id) => + (t + .widget(find.byKey(ValueKey('messageBubble-$id'))) + .decoration! + as BoxDecoration) + .color!; + + testWidgets('their bubbles and a reply to them use their colour', (t) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat( + t, + f, + overrides: [ + peerColorProvider('fred').overrideWithValue(ChatBubbleColor.plum), + ], + ); + expect(fill(t, '5'), ChatBubbleColor.plum.incoming(Brightness.light)); + await t.longPress(bubble('5')); + await t.pumpAndSettle(); + await t.tap(action('reply')); + await t.pumpAndSettle(); + final plum = ChatBubbleColor.plum.accentText(Brightness.light); + expect( + t.widget(find.byKey(const Key('replyPanelTab'))).color, + plum, + ); + expect( + t.widget(find.byKey(const Key('replyPanelAuthor'))).style!.color, + plum, + ); + }); + + testWidgets('before their colour arrives, their bubbles use the default', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + expect(fill(t, '5'), ChatBubbleColors.unknown.incoming(Brightness.light)); + }); +} diff --git a/test/view/chat/reply_panel_test.dart b/test/view/chat/reply_panel_test.dart new file mode 100644 index 0000000..1497a29 --- /dev/null +++ b/test/view/chat/reply_panel_test.dart @@ -0,0 +1,105 @@ +// The bar above the composer while replying: a coloured tab, the name in the +// same colour, one line of the message and a cancel button. The colour is that +// of the person being replied to. +import 'package:fireplace/src/app/chat_color_preference.dart'; +import 'package:fireplace/src/styles/chat_colors.dart'; +import 'package:fireplace/src/view/chat/chat_composer.dart'; +import 'package:fireplace/src/view/chat/widgets/reply_panel.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/ui_fixture.dart'; +import 'long_press_menu_test.dart' show openChat, bubble, action; + +void main() { + Color tab(WidgetTester t) => + t.widget(find.byKey(const Key('replyPanelTab'))).color; + Color name(WidgetTester t) => + t.widget(find.byKey(const Key('replyPanelAuthor'))).style!.color!; + ChatBubbleColors colors(WidgetTester t) => + ProviderScope.containerOf(t.element(find.byType(ChatComposer))) + .read(chatBubbleColorsProvider); + + testWidgets('replying to them uses the colour their messages have here', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + await t.longPress(bubble('5')); // from fred + await t.pumpAndSettle(); + await t.tap(action('reply')); + await t.pumpAndSettle(); + + final panel = find.byKey(const Key('replyComposerPreview')); + expect(t.widget(panel), isA()); + final theirs = colors(t).incoming.accentText(Brightness.light); + expect(tab(t), theirs); + expect(name(t), theirs); + expect( + find.descendant(of: panel, matching: find.text('fred')), + findsOneWidget, + ); + expect( + find.descendant( + of: panel, + matching: find.text('Around two? There is no rush.'), + ), + findsOneWidget, + ); + // The tab starts at the screen's edge and runs the height of the bar. + expect(t.getTopLeft(find.byKey(const Key('replyPanelTab'))).dx, 0); + expect( + t.getSize(find.byKey(const Key('replyPanelTab'))).height, + t.getSize(panel).height, + ); + // One line of the message, however long it is. + expect( + t + .widget( + find.descendant( + of: panel, + matching: find.text('Around two? There is no rush.'), + ), + ) + .maxLines, + 1, + ); + await t.tap(find.byKey(const Key('cancelReply'))); + await t.pumpAndSettle(); + expect(panel, findsNothing); + }); + + testWidgets('replying to your own message uses your primary colour', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await f.chat.store.add( + message( + id: 'mine-1', + body: 'Mine to answer', + at: fixtureTime.add(const Duration(minutes: 5)), + outgoing: true, + ), + ); + await openChat(t, f); + await t.longPress(bubble('mine-1')); + await t.pumpAndSettle(); + await t.tap(action('reply')); + await t.pumpAndSettle(); + final own = colors(t).outgoing.accentText(Brightness.light); + expect(tab(t), own); + expect(name(t), own); + expect( + find.descendant( + of: find.byKey(const Key('replyComposerPreview')), + matching: find.text('You'), + ), + findsOneWidget, + ); + }); +} diff --git a/test/view/groups/group_screens_test.dart b/test/view/groups/group_screens_test.dart index edbfc63..08f8ecc 100644 --- a/test/view/groups/group_screens_test.dart +++ b/test/view/groups/group_screens_test.dart @@ -8,6 +8,7 @@ import 'package:fireplace/src/app/chat_color_preference.dart'; import 'package:fireplace/src/app/group_providers.dart'; import 'package:fireplace/src/styles/chat_colors.dart'; import 'package:fireplace/src/styles/theme.dart'; +import 'package:fireplace/src/view/chat/widgets/message_quote_preview.dart'; import 'package:fireplace/src/view/chat_list/chat_list_screen.dart'; import 'package:fireplace/src/view/groups/group_chat_screen.dart'; import 'package:fireplace/src/view/groups/group_info_screen.dart'; @@ -393,6 +394,78 @@ void main() { await tearDownPhones(t); }); + testWidgets("replying in a group shows the panel in that member's colour " + 'and sends a quote', (t) async { + await setUpPhones(t); + bob.service.ownColor = 'plum'; + final gid = await bookClub(t, alice, bob); + await t.runAsync(() async { + await bob.service.send(gid, 'from Bob'); + await until( + () async => + (await texts(alice, gid)).contains('from Bob') && + alice.service.colorsOf(gid)['bob'] == 'plum', + what: 'the message and the colour', + ); + }); + await pump(t, GroupChatScreen(gid: gid)); + await settle(t, () => find.text('from Bob').evaluate().isNotEmpty); + await t.longPress(find.text('from Bob')); + await t.pumpAndSettle(); + await t.tap(action('reply')); + await t.pumpAndSettle(); + + final panel = find.byKey(const Key('replyComposerPreview')); + expect(panel, findsOneWidget); + final plum = ChatBubbleColor.plum.accentText(Brightness.light); + expect( + t.widget(find.byKey(const Key('replyPanelTab'))).color, + plum, + ); + expect( + t.widget(find.byKey(const Key('replyPanelAuthor'))).style!.color, + plum, + ); + expect(find.descendant(of: panel, matching: find.text('bob')), findsOne); + + await t.enterText(find.byKey(const Key('composer')), 'to Bob'); + await t.pump(); + await t.tap(find.byKey(const Key('send'))); + await settle(t, () => panel.evaluate().isEmpty); + // The send runs under the test's clock, so keep both clocks moving. + var reached = false; + for (var i = 0; i < 200 && !reached; i++) { + await t.runAsync(() async { + final got = (await bob.messages.watch('group-$gid').first) + .where((m) => m.body == 'to Bob') + .firstOrNull; + reached = + got?.quote?.body == 'from Bob' && got?.quote?.senderUid == 'bob'; + await Future.delayed(const Duration(milliseconds: 25)); + }); + await t.pump(const Duration(milliseconds: 25)); + } + expect(reached, isTrue, reason: 'the quote reaches Bob'); + // The bubble names the person quoted. + await settle(t, () => find.text('to Bob').evaluate().isNotEmpty); + expect(find.byType(MessageQuotePreview), findsOneWidget); + expect( + t.widget(find.byType(MessageQuotePreview)).author, + 'bob', + ); + await tearDownPhones(t); + }); + + testWidgets('people are ticked with round boxes when making a group', ( + t, + ) async { + await setUpPhones(t); + await pump(t, const NewGroupScreen()); + final box = t.widget(find.byKey(const Key('pick_bob'))); + expect(box.checkboxShape, isA()); + await tearDownPhones(t); + }); + testWidgets('a group can be muted on this phone, like a 1:1 chat', (t) async { await setUpPhones(t); final gid = await bookClub(t, alice, bob); diff --git a/test/view/settings/accent_color_test.dart b/test/view/settings/accent_color_test.dart index eb3e1c2..0249bf0 100644 --- a/test/view/settings/accent_color_test.dart +++ b/test/view/settings/accent_color_test.dart @@ -103,14 +103,5 @@ void main() { (fill.gradient! as LinearGradient).colors.last, ChatBubbleColor.ocean.outgoing, ); - // The color of received messages is not the accent. - ProviderScope.containerOf(avatar) - .read(chatBubbleColorsProvider.notifier) - .incoming(ChatBubbleColor.plum); - await tester.pumpAndSettle(); - expect( - FireplaceUiTokens.of(tester.element(find.byType(PersonAvatar))).accent, - ChatBubbleColor.ocean.outgoing, - ); }); } diff --git a/test/view/settings/chat_appearance_test.dart b/test/view/settings/chat_appearance_test.dart index f07a490..d5850e9 100644 --- a/test/view/settings/chat_appearance_test.dart +++ b/test/view/settings/chat_appearance_test.dart @@ -107,23 +107,19 @@ void main() { await tester.ensureVisible(find.byKey(const Key('outgoing-ocean'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('outgoing-ocean'))); - await tester.scrollUntilVisible( - find.byKey(const Key('incoming-forest')), - 120, - ); - await tester.ensureVisible(find.byKey(const Key('incoming-forest'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('incoming-forest'))); + // There is one colour to choose: nothing sets other people's. + expect(find.byKey(const Key('incoming-forest')), findsNothing); await tester.pageBack(); await tester.pumpAndSettle(); expect(bubble('out'), ChatBubbleColor.ocean.outgoing); - expect(bubble('in'), ChatBubbleColor.forest.lightIncoming); + // Their messages keep their own colour (the default until it arrives). + expect(bubble('in'), ChatBubbleColor.stone.lightIncoming); await tester.tap(find.text('Choose colors')); await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Reset colors'), 120); - await tester.ensureVisible(find.text('Reset colors')); + await tester.scrollUntilVisible(find.text('Reset color'), 120); + await tester.ensureVisible(find.text('Reset color')); await tester.pumpAndSettle(); - await tester.tap(find.text('Reset colors')); + await tester.tap(find.text('Reset color')); await tester.pageBack(); await tester.pumpAndSettle(); expect(bubble('out'), ChatBubbleColor.ember.outgoing); @@ -158,15 +154,15 @@ void main() { ); await tester.pump(); await tester.scrollUntilVisible( - find.byKey(const Key('incoming-plum')), + find.byKey(const Key('outgoing-plum')), 160, ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('incoming-plum'))); + await tester.tap(find.byKey(const Key('outgoing-plum'))); await tester.pump(); expect( tester - .widget(find.byKey(const Key('incoming-plum'))) + .widget(find.byKey(const Key('outgoing-plum'))) .selected, isTrue, ); diff --git a/test/view/settings/chat_colors_persistence_test.dart b/test/view/settings/chat_colors_persistence_test.dart index f8d1223..cbb9bf1 100644 --- a/test/view/settings/chat_colors_persistence_test.dart +++ b/test/view/settings/chat_colors_persistence_test.dart @@ -70,19 +70,13 @@ void main() { await first .read(chatBubbleColorsProvider.notifier) .outgoing(ChatBubbleColor.plum); - await first - .read(chatBubbleColorsProvider.notifier) - .incoming(ChatBubbleColor.ocean); first.dispose(); expect( await loaded(containerFor(store)), - const ChatBubbleColors( - outgoing: ChatBubbleColor.plum, - incoming: ChatBubbleColor.ocean, - ), + const ChatBubbleColors(outgoing: ChatBubbleColor.plum), ); - // Only the two names are written, under one key. - expect(store.data, {ChatAppearance.storageKey: 'plum,ocean'}); + // Only the colour's name is written, under one key. + expect(store.data, {ChatAppearance.storageKey: 'plum'}); }); test('reset is remembered too', () async { @@ -103,7 +97,9 @@ void main() { 'ocean,unknown,extra', const ChatBubbleColors(outgoing: ChatBubbleColor.ocean), ), - (',forest', const ChatBubbleColors(incoming: ChatBubbleColor.forest)), + // An older save named a second colour for other people's messages. + (',forest', const ChatBubbleColors()), + ('plum,forest', const ChatBubbleColors(outgoing: ChatBubbleColor.plum)), ]) { final store = MemorySecretStore() ..data[ChatAppearance.storageKey] = saved; @@ -148,7 +144,7 @@ void main() { expect(store.data, isEmpty); store.failWrite = false; await controller.outgoing(ChatBubbleColor.forest); - expect(store.data[ChatAppearance.storageKey], 'forest,stone'); + expect(store.data[ChatAppearance.storageKey], 'forest'); }, ); @@ -189,13 +185,13 @@ void main() { ), ); await tester.pump(); - expect(find.textContaining('kept on this device'), findsOneWidget); + expect(find.byKey(const Key('colorExplanation')), findsOneWidget); expect(find.textContaining('reset when the app restarts'), findsNothing); await tester.tap(find.byKey(const ValueKey('outgoing-ocean'))); await tester.pumpAndSettle(); expect( find.text( - 'Could not save your colors. They will last until the app closes.', + 'Could not save your color. It will last until the app closes.', ), findsOneWidget, ); diff --git a/test/view/settings/color_preview_test.dart b/test/view/settings/color_preview_test.dart new file mode 100644 index 0000000..54c6e59 --- /dev/null +++ b/test/view/settings/color_preview_test.dart @@ -0,0 +1,84 @@ +// Settings > Your color: one colour to choose, and a preview of how your +// messages look to you and to other people (decision 0052). +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/db/secret_store.dart'; +import 'package:fireplace/src/styles/chat_colors.dart'; +import 'package:fireplace/src/styles/theme.dart'; +import 'package:fireplace/src/view/settings/chat_appearance.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final brightness in Brightness.values) { + testWidgets('the preview shows what you see and what they see ' + '(${brightness.name})', (t) async { + await t.pumpWidget( + ProviderScope( + overrides: [ + authUserProvider.overrideWithValue(const AsyncData(null)), + secretStoreProvider.overrideWithValue(MemorySecretStore()), + ], + child: MaterialApp( + theme: fireplaceTheme(brightness), + home: const ChatAppearanceScreen(), + ), + ), + ); + await t.pump(); + Color fill(String key) => + (t + .widget( + find.descendant( + of: find.byKey(Key(key)), + matching: find.byType(Container), + ), + ) + .decoration! + as BoxDecoration) + .color!; + expect( + find.text( + 'Choose colors for your messages in all chats. Your color is also ' + 'used for avatars, buttons, and assets within the app.', + ), + findsOneWidget, + ); + expect( + find.text( + 'Applies to all chats. Your choice is kept on this device. It is ' + 'also encrypted in your messages, the server never sees it.', + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('theySeePreview')), + matching: find.text('They see'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('youSeePreview')), + matching: find.text('You see'), + ), + findsOneWidget, + ); + // The default colour, then a chosen one: both bubbles follow it. + expect(fill('youSeePreview'), ChatBubbleColor.ember.outgoing); + expect( + fill('theySeePreview'), + ChatBubbleColor.ember.incoming(brightness), + ); + await t.ensureVisible(find.byKey(const ValueKey('outgoing-plum'))); + await t.tap(find.byKey(const ValueKey('outgoing-plum'))); + await t.pump(); + expect(fill('youSeePreview'), ChatBubbleColor.plum.outgoing); + expect(fill('theySeePreview'), ChatBubbleColor.plum.incoming(brightness)); + // One colour only: nothing chooses other people's. + expect(find.text('Their messages'), findsNothing); + expect(find.byKey(const ValueKey('incoming-plum')), findsNothing); + }); + } +} diff --git a/test/view/settings/color_swatches_test.dart b/test/view/settings/color_swatches_test.dart index bfbaa60..89abdf6 100644 --- a/test/view/settings/color_swatches_test.dart +++ b/test/view/settings/color_swatches_test.dart @@ -40,13 +40,10 @@ void main() { c.outgoing, reason: 'outgoing ${c.label}', ); - expect( - swatch(t, 'incoming-${c.name}'), - c.incoming(b), - reason: 'incoming ${c.label} must show the bubble colour', - ); + // One colour to choose: there is no second row for other people's. + expect(find.byKey(ValueKey('incoming-${c.name}')), findsNothing); // The whole block is the colour, with its name inside. - for (final side in ['outgoing', 'incoming']) { + for (final side in ['outgoing']) { final key = find.byKey(ValueKey('$side-${c.name}')); final fill = t.widget( find.descendant(of: key, matching: find.byType(Material)), @@ -61,10 +58,9 @@ void main() { } } expect(find.byType(ChoiceChip), findsNothing); - // Only the chosen block in each group carries the ring. + // Only the chosen block carries the ring. for (final c in ChatBubbleColor.values) { expect(block(t, 'outgoing-${c.name}').selected, c.name == 'ember'); - expect(block(t, 'incoming-${c.name}').selected, c.name == 'stone'); } Color ring(String key) => ((t diff --git a/test/view/settings/settings_wording_test.dart b/test/view/settings/settings_wording_test.dart index 78c5634..ce1deed 100644 --- a/test/view/settings/settings_wording_test.dart +++ b/test/view/settings/settings_wording_test.dart @@ -45,17 +45,19 @@ Finder _inTile(String key, Finder matching) => void main() { // Real glyph widths: the test font draws every letter as a square. setUpAll(loadFonts); - testWidgets('Chat colors names your colour, then theirs', (t) async { + testWidgets('Your color names the one colour you chose', (t) async { await _openSettings(t); - expect(_inTile('chatColors', find.text('Ember & Stone')), findsOneWidget); + expect(_inTile('chatColors', find.text('Your color')), findsOneWidget); + expect(_inTile('chatColors', find.text('Ember')), findsOneWidget); - await _openSettings(t, savedColors: 'ember,plum'); - expect(_inTile('chatColors', find.text('Ember & Plum')), findsOneWidget); - expect(find.text('Choose your bubble colors'), findsNothing); + // A second colour from an older save is ignored. + await _openSettings(t, savedColors: 'plum,forest'); + expect(_inTile('chatColors', find.text('Plum')), findsOneWidget); + expect(find.textContaining('&'), findsNothing); }); for (final scale in [1.0, 1.35]) { - testWidgets('the longest pair fits on one line on a narrow phone ' + testWidgets('the longest name fits on one line on a narrow phone ' '(text size $scale)', (t) async { await _openSettings( t, @@ -63,7 +65,7 @@ void main() { textScale: scale, savedColors: 'forest,forest', ); - final text = _inTile('chatColors', find.text('Forest & Forest')); + final text = _inTile('chatColors', find.text('Forest')); expect(text, findsOneWidget); final widget = t.widget(text); final painter = TextPainter(