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
76 changes: 76 additions & 0 deletions docs/decisions/0052-one-colour-per-person.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file modified docs/reviews/group-chats/dark-conversation-editing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/reviews/group-chats/dark-conversation-menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/reviews/group-chats/dark-conversation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/reviews/group-chats/dark-joined-later.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/reviews/group-chats/dark-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reviews/group-chats/dark-new-group.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/reviews/group-chats/light-conversation-editing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/reviews/group-chats/light-conversation-menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/reviews/group-chats/light-conversation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/reviews/group-chats/light-joined-later.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/reviews/group-chats/light-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reviews/group-chats/light-new-group.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions docs/reviews/one-colour-and-reply/README.md
Original file line number Diff line number Diff line change
@@ -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
```
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 17 additions & 3 deletions lib/src/app/chat_color_preference.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,7 @@ class ChatAppearance extends Notifier<ChatBubbleColors> {
/// If saving fails the future fails; the choice still lasts until the app
/// closes.
Future<void> outgoing(ChatBubbleColor color) =>
_choose(ChatBubbleColors(outgoing: color, incoming: state.incoming));
Future<void> incoming(ChatBubbleColor color) =>
_choose(ChatBubbleColors(outgoing: state.outgoing, incoming: color));
_choose(ChatBubbleColors(outgoing: color));
Future<void> reset() => _choose(const ChatBubbleColors());

Future<void> _choose(ChatBubbleColors colors) {
Expand All @@ -63,3 +61,19 @@ class ChatAppearance extends Notifier<ChatBubbleColors> {

final chatBubbleColorsProvider =
NotifierProvider<ChatAppearance, ChatBubbleColors>(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<ChatBubbleColor, String>((
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;
});
13 changes: 13 additions & 0 deletions lib/src/app/providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -133,13 +134,17 @@ class AppSession {
required this.safety,
this.pushNotifications,
this.groups,
this.peerColors,
LocalChatPreferences? chatPreferences,
required StreamSubscription<void> chatsSub,
required this.dispose,
this.destroyLocalData,
}) : _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;
Expand Down Expand Up @@ -256,6 +261,11 @@ final appSessionProvider = FutureProvider<AppSession?>((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,
Expand All @@ -266,6 +276,8 @@ final appSessionProvider = FutureProvider<AppSession?>((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.
Expand Down Expand Up @@ -358,6 +370,7 @@ final appSessionProvider = FutureProvider<AppSession?>((ref) async {
}
// 10. Return the session with cleanup and local-data removal.
final session = AppSession(
peerColors: peerColors,
uid: user.uid,
username: username,
device: device,
Expand Down
1 change: 1 addition & 0 deletions lib/src/db/account_erasure.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Future<void> eraseAccountSecrets(SecretStore store, String uid) async {
'msgkey',
'groupkey',
'groupcolors',
'peercolors',
'chatprefskey',
'hidden',
'pushasked',
Expand Down
11 changes: 9 additions & 2 deletions lib/src/model/chat/chat_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ class ChatService {
required LocalMessageStore messages,
SafetyService? safety,
SharedLock? lock,
this.ownColor,
this.onPeerColor,
@visibleForTesting Future<void> Function(WriteBatch batch)? commitBatch,
@visibleForTesting Future<void> Function()? beforeChatListBump,
}) : _db = db,
Expand All @@ -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;
Expand Down Expand Up @@ -116,7 +123,7 @@ class ChatService {
alerts: _alerts,
peerOf: peerOf,
messageRetention: messageRetention,
);
)..ownColor = ownColor;

late final _receiver = MessageReceiver(
db: _db,
Expand All @@ -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).
Expand Down
18 changes: 18 additions & 0 deletions lib/src/model/chat/message_payload.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> encodeMessagePayload({
required String text,
required DateTime now,
MessageQuote? quote,
MessageMutation? mutation,
String? color,
}) => utf8.encode(
jsonEncode({
'v': quote != null || mutation?.kind == MessageMutationKind.edit
Expand All @@ -28,6 +33,7 @@ List<int> encodeMessagePayload({
'type': 'text',
'body': text,
if (quote != null) 'quote': quote.toJson(),
'color': ?color,
} else
...mutation.toJson(),
'ts': now.millisecondsSinceEpoch,
Expand All @@ -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<int> plain) {
Expand All @@ -56,6 +65,7 @@ DecodedPayload decodeMessagePayload(List<int> plain) {
quote: payload['v'] == 3 && payload['quote'] != null
? MessageQuote.fromJson(Map<String, dynamic>.from(payload['quote']))
: null,
color: _colorName(payload['color']),
);
}
if (payload is Map &&
Expand All @@ -68,20 +78,28 @@ DecodedPayload decodeMessagePayload(List<int> plain) {
status: MessageStatus.ok,
mutation: MessageMutation.fromJson(Map<String, dynamic>.from(payload)),
quote: null,
color: null,
);
}
return (
body: '[unsupported message]',
status: MessageStatus.ok,
mutation: null,
quote: null,
color: null,
);
} catch (_) {
return (
body: 'Malformed message.',
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;
9 changes: 8 additions & 1 deletion lib/src/model/chat/message_receiver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions lib/src/model/chat/message_sender.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -164,6 +168,7 @@ class MessageSender {
now: DateTime.now(),
quote: quote,
mutation: mutation,
color: mutation == null ? ownColor?.call() : null,
),
chatId: chatId,
);
Expand Down
Loading
Loading