diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index b308f9b3..4eb64ea2 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -180,7 +180,7 @@ Use **`useIsOneOnOne()`** from `hooks/useRoom.ts` whenever you need the 1:1 vs g - `message/content/` — `EventContent.tsx` is now a **two-branch** sysline renderer (`layout?: 'stream'|'channel'`; `'channel'` → ``, else the Stream rail/dot grid). The legacy `IsStreamProvider` context is gone; rail metadata flows via `railStart`/`railEnd` props. Plus `Image/Video/Audio/File/Thumbnail/FallbackContent`. - `message/attachment/` — `Attachment.tsx` + the Stream-media bubble shells (`StreamMediaShell` aspect-clamped 320px, `StreamMediaImage`, `StreamMediaVideo`). - `message/placeholder/` — `DefaultPlaceholder`, `LinePlaceholder`. -- Top-level: `MessageStatus.tsx` (kept, **not rendered in timeline** — consumed only by `hooks/useMessageStatus.ts` → `hooks/useDotColor.ts`, which encodes delivery/read state on the Stream rail dot: red=failed, green=read, gold=mention, gray=neutral), `Reaction`, `Reply`, `Time`, `RenderBody`, `FileHeader`. +- Top-level: `MessageStatus.tsx` (kept, **not rendered in timeline** — consumed only by `hooks/useMessageStatus.ts` → `hooks/useDotColor.ts`, which encodes delivery/read state on the Stream rail dot, **per side**: OWN dots are **white** (`--vojo-stream-name-own`, matching the own nick), except **green=read & not yet answered** (prominent — demotes back to white once the peer replies, tracked reactively via `RoomEvent.Timeline`); **PEER (incoming) dots are gray** (`--vojo-dot-neutral`); gold=mention / red=failed override either side. Nicks: own=white, peer=brand purple), `Reaction`, `Reply`, `Time`, `RenderBody`, `FileHeader`. ### Editor — `editor/` diff --git a/src/app/components/message/layout/Stream.tsx b/src/app/components/message/layout/Stream.tsx index 32c904d9..435d2286 100644 --- a/src/app/components/message/layout/Stream.tsx +++ b/src/app/components/message/layout/Stream.tsx @@ -207,7 +207,7 @@ export const StreamLayout = as<'div', StreamLayoutProps>( {/* The header prints once at the head of a same-sender run; every continuation row drops it (header is undefined when collapsed), so the nick + timestamp only show on the first message of a run. The - nick colour is per-side: own = white/black, peer (vojo) = lavender. */} + nick colour is per-side: own = white/black, peer = brand purple. */} {header && (
([ + EventType.RoomMessage, + EventType.Sticker, + EventType.RoomMessageEncrypted, +]); + +// Timestamp of the most recent peer (not-me) message in the room's live +// timeline, or 0 if none. Scans from the tail and stops at the first match, +// so it's O(1) in the common case (the peer's last message is near the end). +function getLatestPeerMessageTs(room: Room, myUserId: string): number { + const events = room.getLiveTimeline().getEvents(); + for (let i = events.length - 1; i >= 0; i -= 1) { + const ev = events[i]; + const sender = ev.getSender(); + if (sender && sender !== myUserId && !ev.isRedacted() && PEER_MESSAGE_TYPES.has(ev.getType())) { + return ev.getTs(); + } + } + return 0; +} + // DM «VS Code chat» dot decision tree // (docs/plans/dm_stream_vscode_redesign.md §5): // * mention-highlight (push-actions tweaks.highlight) → gold (Warning), full — override // * own NOT_SENT / CANCELLED → red (Critical), full -// * own sending → gray, dim (in-flight) -// * own read by peer → green (Success), full -// * own sent-not-read → gray, full -// * incoming I have read → gray, dim -// * incoming unread → gray, full +// * own read by peer, no later peer reply → green (Success), full +// * own in-flight / sent / answered → white (own colour) +// * incoming (peer) → uniform gray // -// Unlike the previous Stream palette, the dot no longer carries the author's -// mxid-hash colour — every state is gray/green/gold/red so the rail reads as a -// delivery/read ledger (like the VS Code reference), not an author legend. +// Per-side colours: OWN dots are white (matching the own nick), except a +// read-and-not-yet-answered one which is prominent green (it demotes back to +// white once the peer replies — a reply is itself proof of reading, like +// WhatsApp/Telegram dropping the read check on answered messages; tracked +// reactively via RoomEvent.Timeline). PEER (incoming) dots are gray. Gold +// (mention) / red (failed) still override either side. +// +// The dot does not carry the author's mxid-hash colour — white/gray/green/gold/ +// red only, so the rail reads as a delivery/read ledger (like the VS Code +// reference), not an author legend. // // Mention detection goes through the canonical `mx.getPushActionsForEvent` // path (m.mentions, display-name match, configured keywords, room-mentions via @@ -34,16 +77,9 @@ const DOT_NEUTRAL = 'var(--vojo-dot-neutral)'; // `replacingEvent()` is consulted so a non-mention edit of a mention drops the // gold dot and a mention added via edit lights one up. // -// Read-state for incoming messages is reactive: we subscribe to RoomEvent.Receipt -// so the opacity flips when our own read receipt lands (e.g. another tab marks -// the room read). For own messages, useMessageStatus already wires this up via -// its internal effect. -// // `hideReadReceipts` mirrors the legacy MessageStatus checkmark suppression: -// when the user opted into «Hide Typing & Read Receipts» we MUST NOT reveal -// peer-read state via the dot either, otherwise the privacy guarantee is -// asymmetric. With the flag on, own messages never flip to green — Sent and -// Read both render as a full-opacity gray dot. +// when the user opted into «Hide Typing & Read Receipts» own messages never +// flip to green — they stay the uniform gray like everything else. export function useDotColor( room: Room, mEvent: MatrixEvent, @@ -54,27 +90,35 @@ export function useDotColor( const myUserId = mx.getSafeUserId(); const senderId = mEvent.getSender() ?? ''; const isOwn = senderId === myUserId; - const eventId = mEvent.getId(); const status = useMessageStatus(room, mEvent); - const [haveSeen, setHaveSeen] = useState(() => - enabled && !isOwn && !!eventId ? room.hasUserReadEvent(myUserId, eventId) : false + // Latest peer-reply timestamp — only meaningful for own messages, so the + // listener is skipped entirely on incoming rows. Subscribes to live + // timeline events so an own «read» dot demotes from green the instant the + // peer answers (back-pagination events are ignored — they never add a + // newer reply). + const [latestPeerTs, setLatestPeerTs] = useState(() => + enabled && isOwn ? getLatestPeerMessageTs(room, myUserId) : 0 ); useEffect(() => { - if (!enabled) return undefined; - if (isOwn || !eventId) return undefined; - setHaveSeen(room.hasUserReadEvent(myUserId, eventId)); - const handler: RoomEventHandlerMap[RoomEvent.Receipt] = (_event, r) => { - if (r.roomId !== room.roomId) return; - setHaveSeen(room.hasUserReadEvent(myUserId, eventId)); + if (!enabled || !isOwn) return undefined; + setLatestPeerTs(getLatestPeerMessageTs(room, myUserId)); + const handler: RoomEventHandlerMap[RoomEvent.Timeline] = (_event, r, toStartOfTimeline) => { + // The listener is bound to THIS room, so only skip when the SDK hands us + // a *different* room — `r` is typed optional and can arrive undefined, + // and an `r?.roomId !== room.roomId` guard would then wrongly drop the + // update, leaving a just-answered dot stuck as the big green one. + if (r && r.roomId !== room.roomId) return; + if (toStartOfTimeline) return; + setLatestPeerTs(getLatestPeerMessageTs(room, myUserId)); }; - room.on(RoomEvent.Receipt, handler); + room.on(RoomEvent.Timeline, handler); return () => { - room.removeListener(RoomEvent.Receipt, handler); + room.removeListener(RoomEvent.Timeline, handler); }; - }, [room, myUserId, eventId, isOwn, enabled]); + }, [room, myUserId, isOwn, enabled]); if (!enabled) { return { color: color.Primary.Main, opacity: 1.0, prominent: false }; @@ -89,24 +133,20 @@ export function useDotColor( if (mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED) { return { color: color.Critical.Main, opacity: 1.0, prominent: true }; } - // Sending state always dims, regardless of hideReadReceipts — the in-flight - // signal is local to the sender and reveals nothing about the peer. - if (status === MessageDeliveryStatus.Sending) { - return { color: DOT_NEUTRAL, opacity: 0.45, prominent: false }; + // Read AND still awaiting a reply (peer hasn't messaged since) → prominent + // green. Privacy mode (`hideReadReceipts`) suppresses it so we never reveal + // peer-read state via colour. Everything else (in-flight / sent / answered) + // is the own white below. + if ( + !hideReadReceipts && + status === MessageDeliveryStatus.Read && + latestPeerTs <= mEvent.getTs() + ) { + return { color: color.Success.Main, opacity: 1.0, prominent: true }; } - if (hideReadReceipts) { - // Privacy mode: don't reveal Sent-vs-Read via colour — both render as a - // full-opacity neutral dot, matching the single-checkmark behaviour of - // MessageStatus.tsx when the same flag is on. - return { color: DOT_NEUTRAL, opacity: 1.0, prominent: false }; - } - // Read by the peer → green (prominent); still only delivered → neutral gray. - return status === MessageDeliveryStatus.Read - ? { color: color.Success.Main, opacity: 1.0, prominent: true } - : { color: DOT_NEUTRAL, opacity: 1.0, prominent: false }; + return { color: DOT_OWN, opacity: 1.0, prominent: false }; } - // Incoming: neutral gray, dimmed once we've read it (so a fresh unread - // message still pops on the rail) — no author-hash colour any more. - return { color: DOT_NEUTRAL, opacity: haveSeen ? 0.4 : 1.0, prominent: false }; + // Incoming (peer): gray. + return { color: DOT_NEUTRAL, opacity: 1.0, prominent: false }; } diff --git a/src/index.css b/src/index.css index 5cb95b0a..762a15a0 100644 --- a/src/index.css +++ b/src/index.css @@ -34,13 +34,14 @@ /* DM 1-1 «VS Code chat» redesign tokens (see docs/plans/dm_stream_vscode_redesign.md). Light-theme defaults here; dark overrides in `.dark-theme`. - * stream-name-own — own author label colour (pure black light / - white dark — the original neutral nick colour). - * stream-name-peer — peer (vojo) author label colour (lavender, the - Dawn brand accent). Both per-side, see StreamName. - * dot-neutral — gray rail dot for unread / in-flight (green = my-read, - gold = mention, red = my-failed are folds tokens in - useDotColor). + * stream-name-own — own author label colour AND own rail dots (strong + neutral «white»: black light / white dark). Used by + StreamName and by useDotColor's own resting dot. + * stream-name-peer — peer author label colour (brand purple — the Dawn + lavender accent). + * dot-neutral — gray rail dot for PEER (incoming) dots. (Own dots use + stream-name-own; green = my-read-awaiting-reply, gold = + mention, red = my-failed are folds tokens in useDotColor.) NB: the incoming-bubble fill is NOT a var — `StreamBubble` binds it to `color.Surface.Container` so it always matches the composer card. */ --vojo-stream-name-own: #000000; @@ -72,8 +73,8 @@ /* DM 1-1 «VS Code chat» redesign — dark palette. (Incoming-bubble fill binds to color.Surface.Container in StreamBubble, not a var here.) - Own nick = white (original neutral), peer (vojo) nick = the #9580ff - Dawn brand lavender. */ + Own nick + own dots = white; peer nick = #9580ff Dawn brand lavender; + peer dots = gray (dot-neutral). */ --vojo-stream-name-own: #ffffff; --vojo-stream-name-peer: #9580ff; --vojo-dot-neutral: #6b7280;