import { useEffect, useState } from 'react'; import { EventStatus, EventType, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap, } from 'matrix-js-sdk'; import { color } from 'folds'; import { useMatrixClient } from './useMatrixClient'; import { MessageDeliveryStatus, useMessageStatus } from './useMessageStatus'; // `prominent` is true for the «state» dots (green = read, gold = mention, red // = failed) and false for the neutral gray. The Stream layout draws prominent // 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 — 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([ 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 read by peer, no later peer reply → green (Success), full // * own in-flight / sent / answered → white (own colour) // * incoming (peer) → uniform gray // // 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 // power-level) — same as element-web. Don't roll our own mentionsMe() helper. // // `replacingEvent()` is consulted so a non-mention edit of a mention drops the // gold dot and a mention added via edit lights one up. // // `hideReadReceipts` mirrors the legacy MessageStatus checkmark suppression: // 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, enabled = true, hideReadReceipts = false ): DotColor { const mx = useMatrixClient(); const myUserId = mx.getSafeUserId(); const senderId = mEvent.getSender() ?? ''; const isOwn = senderId === myUserId; const status = useMessageStatus(room, mEvent); // 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 || !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.Timeline, handler); return () => { room.removeListener(RoomEvent.Timeline, handler); }; }, [room, myUserId, isOwn, enabled]); if (!enabled) { return { color: color.Primary.Main, opacity: 1.0, prominent: false }; } const pushActions = mx.getPushActionsForEvent(mEvent.replacingEvent() ?? mEvent); if (pushActions?.tweaks?.highlight) { return { color: color.Warning.Main, opacity: 1.0, prominent: true }; } if (isOwn) { if (mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED) { return { color: color.Critical.Main, opacity: 1.0, prominent: true }; } // 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 }; } return { color: DOT_OWN, opacity: 1.0, prominent: false }; } // Incoming (peer): gray. return { color: DOT_NEUTRAL, opacity: 1.0, prominent: false }; }