vojo/src/app/hooks/useDotColor.ts

107 lines
4.6 KiB
TypeScript

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<boolean>(() =>
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,
};
}