feat(room): colour the DM stream rail per side — white own nick/dots, purple peer nick, gray peer dots, green own dot while awaiting a reply
This commit is contained in:
parent
0e87787f95
commit
0a62fa8e1d
5 changed files with 110 additions and 64 deletions
|
|
@ -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'` → `<ChannelEventContent>`, 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/`
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<div
|
||||
className={classNames(css.StreamName, isOwn ? css.StreamNameOwn : css.StreamNamePeer)}
|
||||
|
|
|
|||
|
|
@ -371,6 +371,10 @@ export const StreamSyslineRailStart = style({
|
|||
// Set as marginTop because Halo is in-flow.
|
||||
export const StreamHeaderDotHalo = style({
|
||||
marginTop: `calc(${StreamHeaderInnerCenterY} - (${StreamDotSize} / 2))`,
|
||||
// Smooth the prominent→neutral shrink when a read (green) dot demotes back to
|
||||
// the own white resting state after the peer replies — the width/height are
|
||||
// set inline in Stream.tsx, this just animates the change.
|
||||
transition: 'width 220ms ease, height 220ms ease',
|
||||
});
|
||||
|
||||
export const StreamDotFill = style({
|
||||
|
|
@ -378,8 +382,9 @@ export const StreamDotFill = style({
|
|||
inset: 0,
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none',
|
||||
// Smooth read↔unread fade — without it the state flip reads as a hard step.
|
||||
transition: 'opacity 220ms ease, filter 220ms ease',
|
||||
// Smooth read↔unread fade + the green→white recolour — without it the state
|
||||
// flips read as a hard step.
|
||||
transition: 'opacity 220ms ease, filter 220ms ease, background-color 220ms ease',
|
||||
});
|
||||
|
||||
// Wrapper for grid track 2 — holds the header line (nick + time), the bubble
|
||||
|
|
@ -540,8 +545,8 @@ export const StreamName = style({
|
|||
});
|
||||
|
||||
// Own / peer nick colour. Applied alongside StreamName so the inner Username
|
||||
// inherits the tint. Own = the original neutral white (dark) / black (light);
|
||||
// peer (the vojo side) = lavender, the Dawn brand accent. Both bind to CSS
|
||||
// inherits the tint. Own = the strong neutral «white» (white dark / black
|
||||
// light); peer = the Dawn brand purple (lavender accent). Both bind to CSS
|
||||
// vars (see index.css) so they stay tunable.
|
||||
export const StreamNameOwn = style({
|
||||
color: 'var(--vojo-stream-name-own)',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { EventStatus, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import {
|
||||
EventStatus,
|
||||
EventType,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomEventHandlerMap,
|
||||
} from 'matrix-js-sdk';
|
||||
import { color } from 'folds';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { MessageDeliveryStatus, useMessageStatus } from './useMessageStatus';
|
||||
|
|
@ -9,23 +16,59 @@ import { MessageDeliveryStatus, useMessageStatus } from './useMessageStatus';
|
|||
// dots 1.1× the size of the neutral ones so they catch the eye on the rail.
|
||||
export type DotColor = { color: string; opacity: number; prominent: boolean };
|
||||
|
||||
// Gray rail dot — neutral state for «unread» (my undelivered / any incoming
|
||||
// not-yet-read). Theme-aware CSS var (light/dark in src/index.css).
|
||||
// Gray rail dot — every PEER (incoming) dot. Flat colour + opacity so the
|
||||
// peer side reads as one uniform gray. Theme-aware CSS var (light/dark in
|
||||
// src/index.css).
|
||||
const DOT_NEUTRAL = 'var(--vojo-dot-neutral)';
|
||||
|
||||
// Own rail dot — «white» (black light / white dark), matching the own nick.
|
||||
// Reuses the own name token so the two can't drift: my colour is white
|
||||
// everywhere on the rail (except the green/red/gold status overrides).
|
||||
const DOT_OWN = 'var(--vojo-stream-name-own)';
|
||||
|
||||
// Event types that count as a «message from the peer» for the read-already-
|
||||
// answered transition. A reaction / membership / redaction from the peer is
|
||||
// NOT a reply, so it must not demote the green dot. Encrypted events are
|
||||
// counted before they decrypt (they're still a message the peer sent).
|
||||
const PEER_MESSAGE_TYPES = new Set<string>([
|
||||
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<boolean>(() =>
|
||||
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<number>(() =>
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue