152 lines
6.6 KiB
TypeScript
152 lines
6.6 KiB
TypeScript
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<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 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<number>(() =>
|
||
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 };
|
||
}
|