import { useEffect, useState } from 'react'; import { EventStatus, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { color } from 'folds'; import { cssColorMXID } from '../../util/colorMXID'; import { useMatrixClient } from './useMatrixClient'; import { MessageDeliveryStatus, useMessageStatus } from './useMessageStatus'; export type DotColor = { color: string; opacity: number }; // Variant A + C decision tree (docs/plans/dm_1x1_redesign.md §6.5b): // * mention-highlight (push-actions tweaks.highlight) → Warning gold, override // * own NOT_SENT / CANCELLED → Critical, full // * own otherwise → Primary, dim → full when peer reads // * incoming → cssColorMXID hash, full → dim once we read // // 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. // // 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 stop dimming once they leave the // «sending» state — Sent and Read both render at full opacity. export function useDotColor( room: Room, mEvent: MatrixEvent, enabled = true, hideReadReceipts = false ): DotColor { const mx = useMatrixClient(); const myUserId = mx.getUserId() ?? ''; const senderId = mEvent.getSender() ?? ''; const isOwn = !!myUserId && senderId === myUserId; const eventId = mEvent.getId(); const status = useMessageStatus(room, mEvent); const [haveSeen, setHaveSeen] = useState(() => enabled && !isOwn && !!eventId && !!myUserId ? room.hasUserReadEvent(myUserId, eventId) : false ); useEffect(() => { if (!enabled) return undefined; if (isOwn || !eventId || !myUserId) 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)); }; room.on(RoomEvent.Receipt, handler); return () => { room.removeListener(RoomEvent.Receipt, handler); }; }, [room, myUserId, eventId, isOwn, enabled]); if (!enabled) { return { color: color.Primary.Main, opacity: 1.0 }; } const pushActions = mx.getPushActionsForEvent(mEvent.replacingEvent() ?? mEvent); if (pushActions?.tweaks?.highlight) { return { color: color.Warning.Main, opacity: 1.0 }; } if (isOwn) { if (mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED) { return { color: color.Critical.Main, opacity: 1.0 }; } // 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: color.Primary.Main, opacity: 0.3 }; } if (hideReadReceipts) { // Privacy mode: don't differentiate Sent vs Read — both render full, // matching the single-checkmark behaviour of MessageStatus.tsx when // the same flag is on. return { color: color.Primary.Main, opacity: 1.0 }; } return { color: color.Primary.Main, // Stronger opacity contrast than the original 0.4: at 0.3 the dot still // reads as Fleet-violet (own author colour) but is clearly less alive // than the 1.0 read state. Author colour is preserved on purpose — the // user explicitly asked NOT to grey-out / desaturate read dots, only to // dim them through opacity. opacity: status === MessageDeliveryStatus.Read ? 1.0 : 0.3, }; } return { color: `var(${cssColorMXID(senderId)})`, // Same logic for incoming dots: keep the author hash colour visible at // all times so group-DM authorship stays scannable; only modulate the // dot's brightness via opacity to mark «I've seen it» (0.3) vs fresh (1.0). opacity: haveSeen ? 0.3 : 1.0, }; }