feat(channels): ship M4 per-thread unread with thread receipts mute-aware atom and architectural cleanup
This commit is contained in:
parent
307af24d1e
commit
e80453785e
14 changed files with 564 additions and 209 deletions
|
|
@ -520,6 +520,10 @@
|
|||
"thread_summary_count_other": "{{count}} replies",
|
||||
"thread_summary_open_thread": "Open thread",
|
||||
"thread_summary_last_reply_by": "last reply from {{name}}",
|
||||
"thread_summary_unread_one": "{{count}} unread",
|
||||
"thread_summary_unread_other": "{{count}} unread",
|
||||
"thread_summary_highlight_one": "{{count}} mention",
|
||||
"thread_summary_highlight_other": "{{count}} mentions",
|
||||
"no_post_permission": "You do not have permission to post in this room",
|
||||
|
||||
"conversation_beginning": "This is the beginning of conversation.",
|
||||
|
|
|
|||
|
|
@ -526,6 +526,14 @@
|
|||
"thread_summary_count_other": "{{count}} ответа",
|
||||
"thread_summary_open_thread": "Открыть тред",
|
||||
"thread_summary_last_reply_by": "последний ответ от {{name}}",
|
||||
"thread_summary_unread_one": "{{count}} непрочитанное",
|
||||
"thread_summary_unread_few": "{{count}} непрочитанных",
|
||||
"thread_summary_unread_many": "{{count}} непрочитанных",
|
||||
"thread_summary_unread_other": "{{count}} непрочитанных",
|
||||
"thread_summary_highlight_one": "{{count}} упоминание",
|
||||
"thread_summary_highlight_few": "{{count}} упоминания",
|
||||
"thread_summary_highlight_many": "{{count}} упоминаний",
|
||||
"thread_summary_highlight_other": "{{count}} упоминания",
|
||||
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
|
||||
|
||||
"conversation_beginning": "Начало переписки.",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChanne
|
|||
import { CHANNELS_THREAD_PATH } from '../../pages/paths';
|
||||
import { getChannelsRoomPath } from '../../pages/pathUtils';
|
||||
import { isBridgedRoom } from '../../utils/room';
|
||||
import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig';
|
||||
|
||||
type RoomProps = {
|
||||
renderRoomView?: (props: { eventId?: string }) => React.ReactNode;
|
||||
|
|
@ -88,22 +89,29 @@ export function Room({ renderRoomView }: RoomProps) {
|
|||
// changes (provider is reactive via `useIsOneOnOneRoom` upstream).
|
||||
const isOneOnOne = useIsOneOnOne();
|
||||
|
||||
const unreadThreadingEnabled = useUnreadThreadingEnabled();
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
useCallback(
|
||||
(evt) => {
|
||||
// Skip Escape-markAsRead when the thread drawer is open. The
|
||||
// existing handler in `state/room/roomToUnread.ts` blind-deletes
|
||||
// ALL room unread on any own receipt — so firing markAsRead
|
||||
// here while the drawer is the active surface would also wipe
|
||||
// unrelated channel-main unread that the user hasn't seen.
|
||||
// M4 will refactor the receipt handler to partition by
|
||||
// thread_id; until then, gate the Escape shortcut.
|
||||
if (isKeyHotkey('escape', evt) && !showThreadDrawer) {
|
||||
// Escape marks the room as read. Pre-M4 the receipt handler
|
||||
// blind-deleted ALL room unread on any own receipt, so the
|
||||
// drawer-open case had to be skipped to avoid wiping thread
|
||||
// unread the user hadn't seen. After the M4 receipt-handler
|
||||
// refactor (PUT-with-fresh, partitioned by thread_id) main and
|
||||
// thread unread are independent, so Escape on the channel main
|
||||
// surface marks main as read and leaves threads alone — works
|
||||
// unconditionally. Under the kill switch the handler reverts
|
||||
// to blind DELETE, so we keep the drawer-open guard there.
|
||||
if (
|
||||
isKeyHotkey('escape', evt) &&
|
||||
(unreadThreadingEnabled || !showThreadDrawer)
|
||||
) {
|
||||
markAsRead(mx, room.roomId, hideActivity);
|
||||
}
|
||||
},
|
||||
[mx, room.roomId, hideActivity, showThreadDrawer]
|
||||
[mx, room.roomId, hideActivity, showThreadDrawer, unreadThreadingEnabled]
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -186,7 +186,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
const [autocompleteQuery, setAutocompleteQuery] =
|
||||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||||
|
||||
const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
|
||||
// Suppress typing notifications from the drawer composer — see
|
||||
// useTypingStatusUpdater for the spec-level reason (m.typing has
|
||||
// no thread_id, so a thread-composer typing event would surface
|
||||
// in the main channel chat as if the user were typing there).
|
||||
const sendTypingStatus = useTypingStatusUpdater(mx, roomId, !!threadId);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
|
|
|
|||
|
|
@ -751,7 +751,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
}
|
||||
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = mEvt.getSender() !== mx.getUserId();
|
||||
// Always instant — never smooth on incoming. Smooth animation
|
||||
// briefly takes the at-bottom anchor out of the
|
||||
// IntersectionObserver's intersection box during the
|
||||
// animation frame, which the 1s debounce sometimes turns
|
||||
// into a false-positive «user scrolled away» → atBottom flips
|
||||
// false → next message stops auto-following. Direct snap
|
||||
// matches Discord / Telegram and the drawer (M4 fix in
|
||||
// ThreadDrawer.tsx) — predictable, no animation surface for
|
||||
// the observer to misinterpret.
|
||||
scrollToBottomRef.current.smooth = false;
|
||||
if (isOwnLiveStreamMessage) {
|
||||
setAtBottom(true);
|
||||
if (!atLiveEndRef.current) {
|
||||
|
|
@ -904,7 +913,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
useCallback(
|
||||
() => ({
|
||||
root: getScrollElement(),
|
||||
rootMargin: '100px',
|
||||
// Forgiving threshold: 100px was too tight — single tall
|
||||
// message + minor touch-scroll inertia easily took users out
|
||||
// of «at-bottom» state, after which incoming events stopped
|
||||
// auto-following. 400px gives roughly two-three Stream rows
|
||||
// of slack before treating the scroll as a deliberate detach,
|
||||
// matching Discord / Slack auto-follow generosity.
|
||||
rootMargin: '400px',
|
||||
}),
|
||||
[getScrollElement]
|
||||
),
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ import { Opts as LinkifyOpts } from 'linkifyjs';
|
|||
import * as css from './ThreadDrawer.css';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import { scrollToBottom } from '../../utils/dom';
|
||||
import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig';
|
||||
import { markAsThreadRead } from '../../utils/notifications';
|
||||
import { useEditor } from '../../components/editor';
|
||||
import {
|
||||
getEditedEvent,
|
||||
|
|
@ -231,8 +235,11 @@ export function ThreadDrawer({
|
|||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||
|
||||
const unreadThreadingEnabled = useUnreadThreadingEnabled();
|
||||
|
||||
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
||||
const spoilerClickHandler = useSpoilerClickHandler();
|
||||
|
||||
|
|
@ -610,14 +617,15 @@ export function ThreadDrawer({
|
|||
})
|
||||
: [];
|
||||
|
||||
// Read receipts intentionally NOT fired here in M2. With
|
||||
// threadSupport: true the SDK auto-attaches `thread_id: rootId` to
|
||||
// any sendReadReceipt call from a thread event, but the existing
|
||||
// receipt handler in `state/room/roomToUnread.ts` does a blind
|
||||
// DELETE on any own receipt — so a thread read would wipe the
|
||||
// whole channel's unread badge including main-timeline messages
|
||||
// the user has not seen. M4 will refactor the handler to partition
|
||||
// by thread_id; until then the drawer leaves unread state alone.
|
||||
// Read receipts (M4): fired on first reveal at-bottom, on reply
|
||||
// growth while at-bottom, and on own send via the layout/effect
|
||||
// pair below. The receipt handler in `state/room/roomToUnread.ts`
|
||||
// partitions by thread_id (PUT-with-fresh instead of blind DELETE)
|
||||
// so a thread receipt no longer wipes main-timeline unread. SDK
|
||||
// auto-attaches `thread_id: rootId` via `threadIdForReceipt(event)`.
|
||||
// Gated on `unreadThreadingEnabled` so the kill switch falls back
|
||||
// to pre-M4 silence (no thread receipts → channel-list badge
|
||||
// semantics match the pre-M4 blind-DELETE handler).
|
||||
|
||||
// Track whether the user is parked at the bottom of the replies
|
||||
// list via IntersectionObserver on the sentinel. Element-web's
|
||||
|
|
@ -670,6 +678,23 @@ export function ThreadDrawer({
|
|||
const repliesCount = replies.length;
|
||||
const lastReplyCountRef = useRef(0);
|
||||
const myUserId = mx.getUserId();
|
||||
|
||||
// Fire a thread-scoped read receipt when the user lands at-bottom on
|
||||
// a thread with replies. SDK auto-routes `thread_id: rootId` via
|
||||
// `threadIdForReceipt(event)` (client.js:2655). Skipped when the
|
||||
// kill switch (`channels.unreadThreading: false`) is set — the
|
||||
// receipt handler in roomToUnread.ts then runs in pre-M4
|
||||
// blind-DELETE mode, so a thread receipt would wipe channel-main
|
||||
// unread (the M2 reason this was deferred). Also skipped when the
|
||||
// tab is backgrounded, mirroring RoomTimeline's `document.hasFocus`
|
||||
// gate so push notifications keep firing for unfocused tabs.
|
||||
const tryMarkThreadRead = useCallback(() => {
|
||||
if (!unreadThreadingEnabled) return;
|
||||
if (!thread) return;
|
||||
if (!document.hasFocus()) return;
|
||||
markAsThreadRead(mx, thread, hideActivity);
|
||||
}, [mx, thread, hideActivity, unreadThreadingEnabled]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const host = scrollHostRef.current;
|
||||
if (!host) return;
|
||||
|
|
@ -678,7 +703,8 @@ export function ThreadDrawer({
|
|||
host.scrollTop = host.scrollHeight;
|
||||
isAtBottomRef.current = true;
|
||||
lastReplyCountRef.current = repliesCount;
|
||||
}, [repliesCount]);
|
||||
tryMarkThreadRead();
|
||||
}, [repliesCount, tryMarkThreadRead]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = scrollHostRef.current;
|
||||
|
|
@ -696,25 +722,49 @@ export function ThreadDrawer({
|
|||
// new arrival from someone else.
|
||||
return;
|
||||
}
|
||||
if (lastIsOwn) {
|
||||
// Own send: jump to bottom instantly — the user just hit Enter,
|
||||
// a smooth scroll animation feels like the input «kicks back»
|
||||
// before settling. Slack/iMessage do the same. Direct scrollTop
|
||||
// write is synchronous; no animation.
|
||||
host.scrollTop = host.scrollHeight;
|
||||
} else {
|
||||
// Reply from someone else while at-bottom: smooth scroll keeps
|
||||
// a visual cue («new reply landed»).
|
||||
bottomSentinelRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end',
|
||||
});
|
||||
}
|
||||
// Always instant — never smooth. Cold-load arrives in 1-2 waves
|
||||
// (SDK cache + mx.relations resolve), so the first growth pass
|
||||
// after initial reveal would otherwise smooth-scroll a few rows
|
||||
// and leak a visible animation on drawer-open. Even for
|
||||
// steady-state live replies a snap-to-bottom matches Discord /
|
||||
// Telegram and beats the «kicks back before settling» feel of a
|
||||
// smooth scrollIntoView. Direct scrollTop write is synchronous —
|
||||
// browser repaints with the new position in the same frame.
|
||||
host.scrollTop = host.scrollHeight;
|
||||
isAtBottomRef.current = true;
|
||||
tryMarkThreadRead();
|
||||
// Intentional: replies array read inside is stable identity-wise
|
||||
// because computed inline; lint disable for the false-positive.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [repliesCount, myUserId]);
|
||||
}, [repliesCount, myUserId, tryMarkThreadRead]);
|
||||
|
||||
// Stay at-bottom when the drawer scroll container resizes — Android
|
||||
// soft-keyboard open/close shrinks the viewport, and Capacitor's
|
||||
// default `windowSoftInputMode=adjustResize` shrinks the WebView
|
||||
// so our scroll host's clientHeight changes. Without this, the
|
||||
// composer pushes up to roughly mid-replies because `scrollHeight`
|
||||
// grows past the new shrunk viewport while `scrollTop` stays put.
|
||||
// Mirrors the keyboard-open fix in `RoomTimeline.tsx::useResizeObserver`
|
||||
// (line 858+). Skip the first observer callback (initial mount fires
|
||||
// synthetic resize) so we don't fight the first-reveal layout-effect.
|
||||
useResizeObserver(
|
||||
useMemo(() => {
|
||||
let mounted = false;
|
||||
return (_entries: ResizeObserverEntry[]) => {
|
||||
if (!mounted) {
|
||||
mounted = true;
|
||||
return;
|
||||
}
|
||||
if (isAtBottomRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollHostRef.current;
|
||||
if (el) scrollToBottom(el);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []),
|
||||
useCallback(() => scrollHostRef.current, [])
|
||||
);
|
||||
|
||||
// Autofocus: drop focus on the close button ONCE per drawer
|
||||
// lifetime so screen-reader / keyboard users land somewhere
|
||||
|
|
|
|||
|
|
@ -39,14 +39,32 @@ export const Root = style({
|
|||
},
|
||||
});
|
||||
|
||||
// 6px dot indicator. Filled `Primary.Main` — on M4 will flip to
|
||||
// `OnContainerLine` for read threads, kept static on M3.
|
||||
// 6px dot indicator. Visible ONLY when the thread has unread content
|
||||
// (M4): `unread` → `Primary.Main` (lavender, total > 0); `highlight`
|
||||
// → `Critical.Main` (mention). On `read` state the dot is hidden
|
||||
// outright via `display: none` — a dim-coloured dot against the pill
|
||||
// bg (`SurfaceVariant.ContainerHover`) rendered at ~1.28:1 contrast,
|
||||
// effectively invisible on dim screens, so the user couldn't tell
|
||||
// «read» apart from «no indicator». Hiding makes the absence
|
||||
// load-bearing: dot present → unread, dot absent → read. Under the
|
||||
// kill switch the parent always passes `read`; the card collapses to
|
||||
// a clean count + time + chevron pill.
|
||||
export const Dot = style({
|
||||
flexShrink: 0,
|
||||
width: toRem(6),
|
||||
height: toRem(6),
|
||||
borderRadius: '50%',
|
||||
backgroundColor: color.Primary.Main,
|
||||
display: 'none',
|
||||
selectors: {
|
||||
'&[data-unread-state="unread"]': {
|
||||
display: 'block',
|
||||
backgroundColor: color.Primary.Main,
|
||||
},
|
||||
'&[data-unread-state="highlight"]': {
|
||||
display: 'block',
|
||||
backgroundColor: color.Critical.Main,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Count = style({
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import { Icon, Icons, Text, as } from 'folds';
|
|||
import {
|
||||
type IThreadBundledRelationship,
|
||||
type MatrixEvent,
|
||||
NotificationCountType,
|
||||
RelationType,
|
||||
type Room,
|
||||
RoomEvent,
|
||||
type Thread,
|
||||
ThreadEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
|
|
@ -17,6 +19,7 @@ import { Time } from '../../components/message';
|
|||
import { getChannelsThreadPath } from '../../pages/pathUtils';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig';
|
||||
|
||||
export type ThreadSummaryCardProps = {
|
||||
room: Room;
|
||||
|
|
@ -94,6 +97,33 @@ export const ThreadSummaryCard = as<'button', ThreadSummaryCardProps>(
|
|||
};
|
||||
}, [thread]);
|
||||
|
||||
// Per-thread unread refresh (M4). `RoomEvent.UnreadNotifications`
|
||||
// fires from three SDK call sites (matrix-js-sdk room.js:1354/1398
|
||||
// /1426): per-thread set (with threadId), per-thread reset from
|
||||
// /sync (NO threadId, room.js:1398), and main-timeline set (no
|
||||
// threadId, room.js:1426). Accept `firedThreadId === rootId` AND
|
||||
// the no-threadId reset/main-timeline cases so a /sync that zeros
|
||||
// all thread counts in one shot still ticks our card. The cost of
|
||||
// a no-op tick on unrelated main-timeline updates is negligible
|
||||
// because we only read a couple of cheap counters. Skipped under
|
||||
// the kill switch — pre-M4 cards don't surface unread state.
|
||||
const unreadThreadingEnabled = useUnreadThreadingEnabled();
|
||||
useEffect(() => {
|
||||
if (!unreadThreadingEnabled || !rootId) return undefined;
|
||||
const handle = (
|
||||
_count: unknown,
|
||||
firedThreadId: string | undefined
|
||||
) => {
|
||||
if (firedThreadId === undefined || firedThreadId === rootId) {
|
||||
setVersion((v) => v + 1);
|
||||
}
|
||||
};
|
||||
room.on(RoomEvent.UnreadNotifications, handle);
|
||||
return () => {
|
||||
room.off(RoomEvent.UnreadNotifications, handle);
|
||||
};
|
||||
}, [room, rootId, unreadThreadingEnabled]);
|
||||
|
||||
const bundled = rootId
|
||||
? rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread)
|
||||
: undefined;
|
||||
|
|
@ -123,8 +153,32 @@ export const ThreadSummaryCard = as<'button', ThreadSummaryCardProps>(
|
|||
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
|
||||
: null;
|
||||
|
||||
// Per-thread unread state. Server-derived totals via SDK; only
|
||||
// consulted when the kill switch is on, so disabled-config or
|
||||
// MSC3773-less servers fall through to the static «read» dot.
|
||||
const unreadTotal = unreadThreadingEnabled
|
||||
? room.getThreadUnreadNotificationCount(rootId, NotificationCountType.Total)
|
||||
: 0;
|
||||
const unreadHighlight = unreadThreadingEnabled
|
||||
? room.getThreadUnreadNotificationCount(rootId, NotificationCountType.Highlight)
|
||||
: 0;
|
||||
let dotState: 'read' | 'unread' | 'highlight' = 'read';
|
||||
if (unreadHighlight > 0) dotState = 'highlight';
|
||||
else if (unreadTotal > 0) dotState = 'unread';
|
||||
|
||||
const countLabel = t('Room.thread_summary_count', { count });
|
||||
const ariaParts = [t('Room.thread_summary_open_thread'), countLabel];
|
||||
if (unreadThreadingEnabled) {
|
||||
// Highlight (mention) is a strict superset of unread for screen
|
||||
// readers: prefer the mention phrasing when applicable so SR
|
||||
// users get parity with sighted users (red dot vs lavender dot).
|
||||
// Fall back to plain unread phrasing if total > 0 but no mention.
|
||||
if (unreadHighlight > 0) {
|
||||
ariaParts.push(t('Room.thread_summary_highlight', { count: unreadHighlight }));
|
||||
} else if (unreadTotal > 0) {
|
||||
ariaParts.push(t('Room.thread_summary_unread', { count: unreadTotal }));
|
||||
}
|
||||
}
|
||||
if (senderDisplayName) {
|
||||
ariaParts.push(t('Room.thread_summary_last_reply_by', { name: senderDisplayName }));
|
||||
}
|
||||
|
|
@ -143,9 +197,10 @@ export const ThreadSummaryCard = as<'button', ThreadSummaryCardProps>(
|
|||
onClick={handleOpen}
|
||||
aria-label={ariaLabel}
|
||||
data-event-id={rootId}
|
||||
data-unread-state={dotState}
|
||||
{...props}
|
||||
>
|
||||
<span className={css.Dot} aria-hidden />
|
||||
<span className={css.Dot} data-unread-state={dotState} aria-hidden />
|
||||
<Text className={css.Count} as="span" size="T200">
|
||||
<b>{countLabel}</b>
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,15 @@ export type BotConfig = {
|
|||
};
|
||||
};
|
||||
|
||||
export type ChannelsConfig = {
|
||||
/** Default true. When false, M4 per-thread unread machinery (badges,
|
||||
* thread-aware receipt handler, drawer markAsRead) falls back to
|
||||
* pre-M4 blind-DELETE semantics. `threadSupport: true` stays on
|
||||
* unconditionally — drawer (M2) reads `room.getThread()` and
|
||||
* ThreadEvent emitters which silently NO-OP without it. */
|
||||
unreadThreading?: boolean;
|
||||
};
|
||||
|
||||
export type ClientConfig = {
|
||||
defaultHomeserver?: number;
|
||||
homeserverList?: string[];
|
||||
|
|
@ -43,8 +52,13 @@ export type ClientConfig = {
|
|||
push?: PushConfig;
|
||||
|
||||
bots?: BotConfig[];
|
||||
|
||||
channels?: ChannelsConfig;
|
||||
};
|
||||
|
||||
export const isUnreadThreadingEnabled = (clientConfig: ClientConfig): boolean =>
|
||||
clientConfig.channels?.unreadThreading !== false;
|
||||
|
||||
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
||||
|
||||
export const ClientConfigProvider = ClientConfigContext.Provider;
|
||||
|
|
@ -55,6 +69,15 @@ export function useClientConfig(): ClientConfig {
|
|||
return config;
|
||||
}
|
||||
|
||||
// Single source of truth for the M4 kill switch. Default true (absent
|
||||
// config = enabled). Consumers should call this hook instead of reading
|
||||
// `useClientConfig()` + `isUnreadThreadingEnabled(...)` separately —
|
||||
// keeps the predicate in one place and lets future telemetry / runtime
|
||||
// flips slot in transparently.
|
||||
export function useUnreadThreadingEnabled(): boolean {
|
||||
return isUnreadThreadingEnabled(useClientConfig());
|
||||
}
|
||||
|
||||
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
|
||||
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,29 @@ import { TYPING_TIMEOUT_MS } from '../state/typingMembers';
|
|||
|
||||
type TypingStatusUpdater = (typing: boolean) => void;
|
||||
|
||||
export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): TypingStatusUpdater => {
|
||||
// `disabled` is the caller's opt-out for surfaces where typing
|
||||
// notifications would leak to the wrong audience. The Matrix
|
||||
// `m.typing` EDU is strictly room-scoped (the spec has no `thread_id`
|
||||
// field), so a thread composer that called `mx.sendTyping(roomId, ...)`
|
||||
// would broadcast «X is typing» to every member watching the main
|
||||
// channel chat — even though the user is privately drafting in a
|
||||
// thread drawer. The drawer passes `disabled=true` to keep the
|
||||
// indicator absent rather than misleading. Returns a stable noop
|
||||
// when disabled so call sites keep their stable identity.
|
||||
export const useTypingStatusUpdater = (
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
disabled = false
|
||||
): TypingStatusUpdater => {
|
||||
const statusSentTsRef = useRef<number>(0);
|
||||
|
||||
const sendTypingStatus: TypingStatusUpdater = useMemo(() => {
|
||||
statusSentTsRef.current = 0;
|
||||
if (disabled) {
|
||||
return () => {
|
||||
/* noop: typing leaks across thread/main boundary by spec */
|
||||
};
|
||||
}
|
||||
return (typing) => {
|
||||
if (typing) {
|
||||
if (Date.now() - statusSentTsRef.current < TYPING_TIMEOUT_MS) {
|
||||
|
|
@ -35,7 +53,7 @@ export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): Typing
|
|||
}
|
||||
statusSentTsRef.current = 0;
|
||||
};
|
||||
}, [mx, roomId]);
|
||||
}, [mx, roomId, disabled]);
|
||||
|
||||
return sendTypingStatus;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,18 +2,22 @@ import { MatrixClient } from 'matrix-js-sdk';
|
|||
import { allInvitesAtom, useBindAllInvitesAtom } from '../room-list/inviteList';
|
||||
import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
|
||||
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
|
||||
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
|
||||
import { roomToUnreadAtom } from '../room/roomToUnread';
|
||||
import { useBindRoomToUnreadAtom } from '../room/useBindRoomToUnreadAtom';
|
||||
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
||||
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
||||
import { useAutoDirectSync } from '../../hooks/useAutoDirectSync';
|
||||
import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig';
|
||||
|
||||
export const useBindAtoms = (mx: MatrixClient) => {
|
||||
const unreadThreadingEnabled = useUnreadThreadingEnabled();
|
||||
|
||||
useBindMDirectAtom(mx, mDirectAtom);
|
||||
useAutoDirectSync(mx);
|
||||
useBindAllInvitesAtom(mx, allInvitesAtom);
|
||||
useBindAllRoomsAtom(mx, allRoomsAtom);
|
||||
useBindRoomToParentsAtom(mx, roomToParentsAtom);
|
||||
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
||||
useBindRoomToUnreadAtom(mx, roomToUnreadAtom, unreadThreadingEnabled);
|
||||
|
||||
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,34 +1,14 @@
|
|||
import produce from 'immer';
|
||||
import { atom, useSetAtom } from 'jotai';
|
||||
import {
|
||||
IRoomTimelineData,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
RoomEvent,
|
||||
SyncState,
|
||||
} from 'matrix-js-sdk';
|
||||
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Membership,
|
||||
NotificationType,
|
||||
RoomToUnread,
|
||||
UnreadInfo,
|
||||
Unread,
|
||||
StateEvent,
|
||||
} from '../../../types/matrix/room';
|
||||
import {
|
||||
getAllParents,
|
||||
getNotificationType,
|
||||
getUnreadInfo,
|
||||
getUnreadInfos,
|
||||
isNotificationEvent,
|
||||
} from '../../utils/room';
|
||||
import { atom } from 'jotai';
|
||||
import { RoomToUnread, UnreadInfo, Unread } from '../../../types/matrix/room';
|
||||
import { getAllParents } from '../../utils/room';
|
||||
import { roomToParentsAtom } from './roomToParents';
|
||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||
import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { useRoomsNotificationPreferencesContext } from '../../hooks/useRoomsNotificationPreferences';
|
||||
|
||||
// Pure reducer + helpers for the room → unread aggregate. The listener
|
||||
// wiring that drives this atom lives next door in
|
||||
// `useBindRoomToUnreadAtom.ts` — keeping the data shape file free of
|
||||
// MatrixClient / React lets the reducer stay testable in isolation
|
||||
// and shrinks the surface area each module is responsible for.
|
||||
|
||||
export type RoomToUnreadAction =
|
||||
| {
|
||||
|
|
@ -165,118 +145,3 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
|
|||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roomToUnreadAtom) => {
|
||||
const setUnreadAtom = useSetAtom(unreadAtom);
|
||||
const roomsNotificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
|
||||
useEffect(() => {
|
||||
setUnreadAtom({
|
||||
type: 'RESET',
|
||||
unreadInfos: getUnreadInfos(mx),
|
||||
});
|
||||
}, [mx, setUnreadAtom]);
|
||||
|
||||
useSyncState(
|
||||
mx,
|
||||
useCallback(
|
||||
(state, prevState) => {
|
||||
if (
|
||||
(state === SyncState.Prepared && prevState === null) ||
|
||||
(state === SyncState.Syncing && prevState !== SyncState.Syncing)
|
||||
) {
|
||||
setUnreadAtom({
|
||||
type: 'RESET',
|
||||
unreadInfos: getUnreadInfos(mx),
|
||||
});
|
||||
}
|
||||
},
|
||||
[mx, setUnreadAtom]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTimelineEvent = (
|
||||
mEvent: MatrixEvent,
|
||||
room: Room | undefined,
|
||||
toStartOfTimeline: boolean | undefined,
|
||||
removed: boolean,
|
||||
data: IRoomTimelineData
|
||||
) => {
|
||||
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
|
||||
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
|
||||
setUnreadAtom({
|
||||
type: 'DELETE',
|
||||
roomId: room.roomId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (mEvent.getSender() === mx.getUserId()) return;
|
||||
setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
|
||||
};
|
||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||
};
|
||||
}, [mx, setUnreadAtom]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleReceipt = (mEvent: MatrixEvent, room: Room) => {
|
||||
const myUserId = mx.getUserId();
|
||||
if (!myUserId) return;
|
||||
if (room.isSpaceRoom()) return;
|
||||
const content = mEvent.getContent<ReceiptContent>();
|
||||
|
||||
const isMyReceipt = Object.keys(content).find((eventId) =>
|
||||
(Object.keys(content[eventId]) as ReceiptType[]).find(
|
||||
(receiptType) => content[eventId][receiptType][myUserId]
|
||||
)
|
||||
);
|
||||
if (isMyReceipt) {
|
||||
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||
}
|
||||
};
|
||||
mx.on(RoomEvent.Receipt, handleReceipt);
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.Receipt, handleReceipt);
|
||||
};
|
||||
}, [mx, setUnreadAtom]);
|
||||
|
||||
useEffect(() => {
|
||||
setUnreadAtom({
|
||||
type: 'RESET',
|
||||
unreadInfos: getUnreadInfos(mx),
|
||||
});
|
||||
}, [mx, setUnreadAtom, roomsNotificationPreferences]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMembershipChange = (room: Room, membership: string) => {
|
||||
if (membership !== Membership.Join) {
|
||||
setUnreadAtom({
|
||||
type: 'DELETE',
|
||||
roomId: room.roomId,
|
||||
});
|
||||
}
|
||||
};
|
||||
mx.on(RoomEvent.MyMembership, handleMembershipChange);
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
|
||||
};
|
||||
}, [mx, setUnreadAtom]);
|
||||
|
||||
useStateEventCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(mEvent) => {
|
||||
if (mEvent.getType() === StateEvent.SpaceChild) {
|
||||
setUnreadAtom({
|
||||
type: 'RESET',
|
||||
unreadInfos: getUnreadInfos(mx),
|
||||
});
|
||||
}
|
||||
},
|
||||
[mx, setUnreadAtom]
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
|
|||
260
src/app/state/room/useBindRoomToUnreadAtom.ts
Normal file
260
src/app/state/room/useBindRoomToUnreadAtom.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import {
|
||||
ClientEvent,
|
||||
IRoomTimelineData,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
RoomEvent,
|
||||
SyncState,
|
||||
} from 'matrix-js-sdk';
|
||||
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
|
||||
import { Membership, NotificationType, StateEvent } from '../../../types/matrix/room';
|
||||
import {
|
||||
getNotificationType,
|
||||
getUnreadInfo,
|
||||
getUnreadInfos,
|
||||
isNotificationEvent,
|
||||
} from '../../utils/room';
|
||||
import { roomToUnreadAtom } from './roomToUnread';
|
||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||
import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { useRoomsNotificationPreferencesContext } from '../../hooks/useRoomsNotificationPreferences';
|
||||
|
||||
// Wires the SDK signals (Timeline, Receipt, UnreadNotifications,
|
||||
// MyMembership, sync state, space-child) into `roomToUnreadAtom`.
|
||||
// Split from the atom file so the reducer + atom shape stay free of
|
||||
// MatrixClient / React imports — see `roomToUnread.ts` for the data
|
||||
// layer.
|
||||
export const useBindRoomToUnreadAtom = (
|
||||
mx: MatrixClient,
|
||||
unreadAtom: typeof roomToUnreadAtom,
|
||||
unreadThreadingEnabled: boolean
|
||||
) => {
|
||||
const setUnreadAtom = useSetAtom(unreadAtom);
|
||||
const roomsNotificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
|
||||
useEffect(() => {
|
||||
setUnreadAtom({
|
||||
type: 'RESET',
|
||||
unreadInfos: getUnreadInfos(mx),
|
||||
});
|
||||
}, [mx, setUnreadAtom]);
|
||||
|
||||
useSyncState(
|
||||
mx,
|
||||
useCallback(
|
||||
(state, prevState) => {
|
||||
if (
|
||||
(state === SyncState.Prepared && prevState === null) ||
|
||||
(state === SyncState.Syncing && prevState !== SyncState.Syncing)
|
||||
) {
|
||||
setUnreadAtom({
|
||||
type: 'RESET',
|
||||
unreadInfos: getUnreadInfos(mx),
|
||||
});
|
||||
}
|
||||
},
|
||||
[mx, setUnreadAtom]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTimelineEvent = (
|
||||
mEvent: MatrixEvent,
|
||||
room: Room | undefined,
|
||||
toStartOfTimeline: boolean | undefined,
|
||||
removed: boolean,
|
||||
data: IRoomTimelineData
|
||||
) => {
|
||||
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
|
||||
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
|
||||
setUnreadAtom({
|
||||
type: 'DELETE',
|
||||
roomId: room.roomId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (mEvent.getSender() === mx.getUserId()) return;
|
||||
setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
|
||||
};
|
||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||
};
|
||||
}, [mx, setUnreadAtom]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleReceipt = (mEvent: MatrixEvent, room: Room) => {
|
||||
const myUserId = mx.getUserId();
|
||||
if (!myUserId) return;
|
||||
if (room.isSpaceRoom()) return;
|
||||
const content = mEvent.getContent<ReceiptContent>();
|
||||
|
||||
const isMyReceipt = Object.keys(content).find((eventId) =>
|
||||
(Object.keys(content[eventId]) as ReceiptType[]).find(
|
||||
(receiptType) => content[eventId][receiptType][myUserId]
|
||||
)
|
||||
);
|
||||
if (!isMyReceipt) return;
|
||||
|
||||
// Pre-M4 fallback: blind DELETE wipes the whole room's unread on
|
||||
// any own receipt. Acceptable when threading is disabled because
|
||||
// there are no per-thread receipts to honour separately.
|
||||
if (!unreadThreadingEnabled) {
|
||||
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Mute gate — pre-M4 the blind-DELETE path implicitly respected
|
||||
// mute (DELETE is a no-op for muted rooms which were never in the
|
||||
// atom). PUT-with-fresh would re-introduce a muted room into the
|
||||
// unread index whenever its `getUnreadInfo` returned non-zero
|
||||
// (e.g. server-side counters for a muted room still tick on
|
||||
// pings). Mirrors the mute branch in the Timeline + UnreadNotifications
|
||||
// handlers above/below.
|
||||
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
|
||||
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||
return;
|
||||
}
|
||||
|
||||
// With per-thread unread on, an own receipt no longer implies the
|
||||
// user read every thread in the room — SDK already partitioned
|
||||
// counters by thread_id before firing this event, so trust it.
|
||||
// `getUnreadInfo` reads `room.getUnreadNotificationCount(Total)`
|
||||
// which sums main + all threads. If the receipt was for the main
|
||||
// timeline, threaded counts stay; if for a thread, the rest stays.
|
||||
const fresh = getUnreadInfo(room);
|
||||
if (fresh.total === 0 && fresh.highlight === 0) {
|
||||
// Explicit DELETE: putUnreadInfo would write a `{ total: 0,
|
||||
// highlight: 0, from: null }` entry into the Map, which keeps
|
||||
// the room in the index and breaks parent rollup deltas on the
|
||||
// next PUT.
|
||||
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||
} else {
|
||||
setUnreadAtom({ type: 'PUT', unreadInfo: fresh });
|
||||
}
|
||||
};
|
||||
mx.on(RoomEvent.Receipt, handleReceipt);
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.Receipt, handleReceipt);
|
||||
};
|
||||
}, [mx, setUnreadAtom, unreadThreadingEnabled]);
|
||||
|
||||
// RoomEvent.UnreadNotifications is the canonical signal for unread
|
||||
// count change — fired by the SDK whenever main-timeline or
|
||||
// per-thread totals shift (room.js:1354 / 1398 / 1426). Catches three
|
||||
// scenarios the Receipt + Timeline handlers miss:
|
||||
//
|
||||
// (a) Local-echo own receipt: `addLocalEchoReceipt` emits
|
||||
// `RoomEvent.Receipt` synchronously but only zeros notification
|
||||
// counters when reading own last event AND non-synthetic — for
|
||||
// drawer-fired thread receipts, counters stay stale until the
|
||||
// server echoes back via /sync. The /sync echo updates counters
|
||||
// before firing UnreadNotifications, so handler reads fresh.
|
||||
// (b) Cross-device own receipt arriving via /sync ephemeral.
|
||||
// (c) Encrypted-room decryption fixing notification counts after the
|
||||
// fact (`fixNotificationCountOnDecryption` in client.js).
|
||||
//
|
||||
// Per-Room event — not re-emitted to the client. Attach to existing
|
||||
// rooms on mount + listen to ClientEvent.Room for new joins. Mirrors
|
||||
// pattern in roomToParents.ts. Skipped when the kill switch is off
|
||||
// — pre-M4 only listened to Receipt (blind DELETE), so the legacy
|
||||
// path stays bit-equivalent without this listener.
|
||||
useEffect(() => {
|
||||
if (!unreadThreadingEnabled) return undefined;
|
||||
|
||||
const recompute = (room: Room) => {
|
||||
if (room.isSpaceRoom()) return;
|
||||
// Skip rooms the user no longer belongs to — late UnreadNotifications
|
||||
// emits (e.g. queued before MyMembership DELETE flushed) would
|
||||
// otherwise re-PUT a left room into the unread index.
|
||||
if (room.getMyMembership() !== Membership.Join) return;
|
||||
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
|
||||
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||
return;
|
||||
}
|
||||
const fresh = getUnreadInfo(room);
|
||||
if (fresh.total === 0 && fresh.highlight === 0) {
|
||||
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||
} else {
|
||||
setUnreadAtom({ type: 'PUT', unreadInfo: fresh });
|
||||
}
|
||||
};
|
||||
|
||||
const handlers = new Map<Room, () => void>();
|
||||
const attach = (room: Room) => {
|
||||
if (handlers.has(room)) return;
|
||||
const handler = () => recompute(room);
|
||||
room.on(RoomEvent.UnreadNotifications, handler);
|
||||
handlers.set(room, handler);
|
||||
};
|
||||
const detach = (room: Room) => {
|
||||
const handler = handlers.get(room);
|
||||
if (!handler) return;
|
||||
room.removeListener(RoomEvent.UnreadNotifications, handler);
|
||||
handlers.delete(room);
|
||||
};
|
||||
const handleNewRoom = (room: Room) => attach(room);
|
||||
// ClientEvent.DeleteRoom fires when the SDK drops a room (rejected
|
||||
// invite, forget, etc) — release the per-room handler ref so the
|
||||
// Map doesn't grow unbounded across long-running sessions.
|
||||
const handleDeleteRoom = (deletedRoomId: string) => {
|
||||
handlers.forEach((_handler, room) => {
|
||||
if (room.roomId === deletedRoomId) detach(room);
|
||||
});
|
||||
};
|
||||
|
||||
mx.getRooms().forEach(attach);
|
||||
mx.on(ClientEvent.Room, handleNewRoom);
|
||||
mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
|
||||
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.Room, handleNewRoom);
|
||||
mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
|
||||
handlers.forEach((handler, room) => {
|
||||
room.removeListener(RoomEvent.UnreadNotifications, handler);
|
||||
});
|
||||
handlers.clear();
|
||||
};
|
||||
}, [mx, setUnreadAtom, unreadThreadingEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
setUnreadAtom({
|
||||
type: 'RESET',
|
||||
unreadInfos: getUnreadInfos(mx),
|
||||
});
|
||||
}, [mx, setUnreadAtom, roomsNotificationPreferences]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMembershipChange = (room: Room, membership: string) => {
|
||||
if (membership !== Membership.Join) {
|
||||
setUnreadAtom({
|
||||
type: 'DELETE',
|
||||
roomId: room.roomId,
|
||||
});
|
||||
}
|
||||
};
|
||||
mx.on(RoomEvent.MyMembership, handleMembershipChange);
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
|
||||
};
|
||||
}, [mx, setUnreadAtom]);
|
||||
|
||||
useStateEventCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(mEvent) => {
|
||||
if (mEvent.getType() === StateEvent.SpaceChild) {
|
||||
setUnreadAtom({
|
||||
type: 'RESET',
|
||||
unreadInfos: getUnreadInfos(mx),
|
||||
});
|
||||
}
|
||||
},
|
||||
[mx, setUnreadAtom]
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
@ -1,26 +1,49 @@
|
|||
import { MatrixClient, ReceiptType } from 'matrix-js-sdk';
|
||||
import { MatrixClient, MatrixEvent, ReceiptType, Thread } from 'matrix-js-sdk';
|
||||
|
||||
// Walks a timeline backwards and returns the latest event that is
|
||||
// fully sent to the homeserver (not local-echo / pending). Shared by
|
||||
// the main-room and thread receipt senders so the «which event is
|
||||
// the read marker pointed at» semantic stays consistent.
|
||||
//
|
||||
// Note: deliberately does NOT skip on `evt.getId() === readEventId`.
|
||||
// SDK's `addLocalEchoReceipt` updates the local read-up-to marker
|
||||
// synchronously on every `sendReadReceipt` call (read-receipt.js:329-332),
|
||||
// so a readEventId-match short-circuit would bail on the second
|
||||
// invocation even when the server failed to process the first one
|
||||
// (network failure, 5xx, MSC3773-less server) — leaving the unread
|
||||
// counters stuck. The server is idempotent on duplicate receipts, so
|
||||
// re-sending costs at most one round-trip; callers gate on
|
||||
// `document.hasFocus()` to avoid spam from backgrounded tabs.
|
||||
const pickLatestNonPending = (events: MatrixEvent[]): MatrixEvent | null => {
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const evt = events[i];
|
||||
if (evt && !evt.isSending()) {
|
||||
return evt;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const receiptType = (privateReceipt: boolean): ReceiptType =>
|
||||
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read;
|
||||
|
||||
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return;
|
||||
|
||||
const timeline = room.getLiveTimeline().getEvents();
|
||||
const readEventId = room.getEventReadUpTo(mx.getUserId()!);
|
||||
|
||||
const getLatestValidEvent = () => {
|
||||
for (let i = timeline.length - 1; i >= 0; i -= 1) {
|
||||
const latestEvent = timeline[i];
|
||||
if (latestEvent.getId() === readEventId) return null;
|
||||
if (!latestEvent.isSending()) return latestEvent;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
if (timeline.length === 0) return;
|
||||
const latestEvent = getLatestValidEvent();
|
||||
if (latestEvent === null) return;
|
||||
|
||||
await mx.sendReadReceipt(
|
||||
latestEvent,
|
||||
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read
|
||||
);
|
||||
const latestEvent = pickLatestNonPending(room.getLiveTimeline().getEvents());
|
||||
if (!latestEvent) return;
|
||||
await mx.sendReadReceipt(latestEvent, receiptType(privateReceipt));
|
||||
}
|
||||
|
||||
// Thread-scoped variant. SDK auto-attaches `thread_id: <rootId>` via
|
||||
// `threadIdForReceipt(event)` (matrix-js-sdk client.js:2655).
|
||||
export async function markAsThreadRead(
|
||||
mx: MatrixClient,
|
||||
thread: Thread,
|
||||
privateReceipt: boolean
|
||||
) {
|
||||
if (!mx.getUserId()) return;
|
||||
const latestEvent = pickLatestNonPending(thread.liveTimeline.getEvents());
|
||||
if (!latestEvent) return;
|
||||
await mx.sendReadReceipt(latestEvent, receiptType(privateReceipt));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue