vojo/src/app/hooks/useDotColor.ts

152 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };
}