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_count_other": "{{count}} replies",
|
||||||
"thread_summary_open_thread": "Open thread",
|
"thread_summary_open_thread": "Open thread",
|
||||||
"thread_summary_last_reply_by": "last reply from {{name}}",
|
"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",
|
"no_post_permission": "You do not have permission to post in this room",
|
||||||
|
|
||||||
"conversation_beginning": "This is the beginning of conversation.",
|
"conversation_beginning": "This is the beginning of conversation.",
|
||||||
|
|
|
||||||
|
|
@ -526,6 +526,14 @@
|
||||||
"thread_summary_count_other": "{{count}} ответа",
|
"thread_summary_count_other": "{{count}} ответа",
|
||||||
"thread_summary_open_thread": "Открыть тред",
|
"thread_summary_open_thread": "Открыть тред",
|
||||||
"thread_summary_last_reply_by": "последний ответ от {{name}}",
|
"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": "У вас нет разрешения на отправку сообщений в этой комнате",
|
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
|
||||||
|
|
||||||
"conversation_beginning": "Начало переписки.",
|
"conversation_beginning": "Начало переписки.",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChanne
|
||||||
import { CHANNELS_THREAD_PATH } from '../../pages/paths';
|
import { CHANNELS_THREAD_PATH } from '../../pages/paths';
|
||||||
import { getChannelsRoomPath } from '../../pages/pathUtils';
|
import { getChannelsRoomPath } from '../../pages/pathUtils';
|
||||||
import { isBridgedRoom } from '../../utils/room';
|
import { isBridgedRoom } from '../../utils/room';
|
||||||
|
import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig';
|
||||||
|
|
||||||
type RoomProps = {
|
type RoomProps = {
|
||||||
renderRoomView?: (props: { eventId?: string }) => React.ReactNode;
|
renderRoomView?: (props: { eventId?: string }) => React.ReactNode;
|
||||||
|
|
@ -88,22 +89,29 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
// changes (provider is reactive via `useIsOneOnOneRoom` upstream).
|
// changes (provider is reactive via `useIsOneOnOneRoom` upstream).
|
||||||
const isOneOnOne = useIsOneOnOne();
|
const isOneOnOne = useIsOneOnOne();
|
||||||
|
|
||||||
|
const unreadThreadingEnabled = useUnreadThreadingEnabled();
|
||||||
|
|
||||||
useKeyDown(
|
useKeyDown(
|
||||||
window,
|
window,
|
||||||
useCallback(
|
useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
// Skip Escape-markAsRead when the thread drawer is open. The
|
// Escape marks the room as read. Pre-M4 the receipt handler
|
||||||
// existing handler in `state/room/roomToUnread.ts` blind-deletes
|
// blind-deleted ALL room unread on any own receipt, so the
|
||||||
// ALL room unread on any own receipt — so firing markAsRead
|
// drawer-open case had to be skipped to avoid wiping thread
|
||||||
// here while the drawer is the active surface would also wipe
|
// unread the user hadn't seen. After the M4 receipt-handler
|
||||||
// unrelated channel-main unread that the user hasn't seen.
|
// refactor (PUT-with-fresh, partitioned by thread_id) main and
|
||||||
// M4 will refactor the receipt handler to partition by
|
// thread unread are independent, so Escape on the channel main
|
||||||
// thread_id; until then, gate the Escape shortcut.
|
// surface marks main as read and leaves threads alone — works
|
||||||
if (isKeyHotkey('escape', evt) && !showThreadDrawer) {
|
// 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);
|
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] =
|
const [autocompleteQuery, setAutocompleteQuery] =
|
||||||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
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(
|
const handleFiles = useCallback(
|
||||||
async (files: File[]) => {
|
async (files: File[]) => {
|
||||||
|
|
|
||||||
|
|
@ -751,7 +751,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToBottomRef.current.count += 1;
|
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) {
|
if (isOwnLiveStreamMessage) {
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
if (!atLiveEndRef.current) {
|
if (!atLiveEndRef.current) {
|
||||||
|
|
@ -904,7 +913,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
useCallback(
|
useCallback(
|
||||||
() => ({
|
() => ({
|
||||||
root: getScrollElement(),
|
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]
|
[getScrollElement]
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
import * as css from './ThreadDrawer.css';
|
import * as css from './ThreadDrawer.css';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
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 { useEditor } from '../../components/editor';
|
||||||
import {
|
import {
|
||||||
getEditedEvent,
|
getEditedEvent,
|
||||||
|
|
@ -231,8 +235,11 @@ export function ThreadDrawer({
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||||
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||||
|
|
||||||
|
const unreadThreadingEnabled = useUnreadThreadingEnabled();
|
||||||
|
|
||||||
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
||||||
const spoilerClickHandler = useSpoilerClickHandler();
|
const spoilerClickHandler = useSpoilerClickHandler();
|
||||||
|
|
||||||
|
|
@ -610,14 +617,15 @@ export function ThreadDrawer({
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Read receipts intentionally NOT fired here in M2. With
|
// Read receipts (M4): fired on first reveal at-bottom, on reply
|
||||||
// threadSupport: true the SDK auto-attaches `thread_id: rootId` to
|
// growth while at-bottom, and on own send via the layout/effect
|
||||||
// any sendReadReceipt call from a thread event, but the existing
|
// pair below. The receipt handler in `state/room/roomToUnread.ts`
|
||||||
// receipt handler in `state/room/roomToUnread.ts` does a blind
|
// partitions by thread_id (PUT-with-fresh instead of blind DELETE)
|
||||||
// DELETE on any own receipt — so a thread read would wipe the
|
// so a thread receipt no longer wipes main-timeline unread. SDK
|
||||||
// whole channel's unread badge including main-timeline messages
|
// auto-attaches `thread_id: rootId` via `threadIdForReceipt(event)`.
|
||||||
// the user has not seen. M4 will refactor the handler to partition
|
// Gated on `unreadThreadingEnabled` so the kill switch falls back
|
||||||
// by thread_id; until then the drawer leaves unread state alone.
|
// 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
|
// Track whether the user is parked at the bottom of the replies
|
||||||
// list via IntersectionObserver on the sentinel. Element-web's
|
// list via IntersectionObserver on the sentinel. Element-web's
|
||||||
|
|
@ -670,6 +678,23 @@ export function ThreadDrawer({
|
||||||
const repliesCount = replies.length;
|
const repliesCount = replies.length;
|
||||||
const lastReplyCountRef = useRef(0);
|
const lastReplyCountRef = useRef(0);
|
||||||
const myUserId = mx.getUserId();
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
const host = scrollHostRef.current;
|
const host = scrollHostRef.current;
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
@ -678,7 +703,8 @@ export function ThreadDrawer({
|
||||||
host.scrollTop = host.scrollHeight;
|
host.scrollTop = host.scrollHeight;
|
||||||
isAtBottomRef.current = true;
|
isAtBottomRef.current = true;
|
||||||
lastReplyCountRef.current = repliesCount;
|
lastReplyCountRef.current = repliesCount;
|
||||||
}, [repliesCount]);
|
tryMarkThreadRead();
|
||||||
|
}, [repliesCount, tryMarkThreadRead]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = scrollHostRef.current;
|
const host = scrollHostRef.current;
|
||||||
|
|
@ -696,25 +722,49 @@ export function ThreadDrawer({
|
||||||
// new arrival from someone else.
|
// new arrival from someone else.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (lastIsOwn) {
|
// Always instant — never smooth. Cold-load arrives in 1-2 waves
|
||||||
// Own send: jump to bottom instantly — the user just hit Enter,
|
// (SDK cache + mx.relations resolve), so the first growth pass
|
||||||
// a smooth scroll animation feels like the input «kicks back»
|
// after initial reveal would otherwise smooth-scroll a few rows
|
||||||
// before settling. Slack/iMessage do the same. Direct scrollTop
|
// and leak a visible animation on drawer-open. Even for
|
||||||
// write is synchronous; no animation.
|
// steady-state live replies a snap-to-bottom matches Discord /
|
||||||
host.scrollTop = host.scrollHeight;
|
// Telegram and beats the «kicks back before settling» feel of a
|
||||||
} else {
|
// smooth scrollIntoView. Direct scrollTop write is synchronous —
|
||||||
// Reply from someone else while at-bottom: smooth scroll keeps
|
// browser repaints with the new position in the same frame.
|
||||||
// a visual cue («new reply landed»).
|
host.scrollTop = host.scrollHeight;
|
||||||
bottomSentinelRef.current.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'end',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
isAtBottomRef.current = true;
|
isAtBottomRef.current = true;
|
||||||
|
tryMarkThreadRead();
|
||||||
// Intentional: replies array read inside is stable identity-wise
|
// Intentional: replies array read inside is stable identity-wise
|
||||||
// because computed inline; lint disable for the false-positive.
|
// because computed inline; lint disable for the false-positive.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
// Autofocus: drop focus on the close button ONCE per drawer
|
||||||
// lifetime so screen-reader / keyboard users land somewhere
|
// 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
|
// 6px dot indicator. Visible ONLY when the thread has unread content
|
||||||
// `OnContainerLine` for read threads, kept static on M3.
|
// (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({
|
export const Dot = style({
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
width: toRem(6),
|
width: toRem(6),
|
||||||
height: toRem(6),
|
height: toRem(6),
|
||||||
borderRadius: '50%',
|
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({
|
export const Count = style({
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import { Icon, Icons, Text, as } from 'folds';
|
||||||
import {
|
import {
|
||||||
type IThreadBundledRelationship,
|
type IThreadBundledRelationship,
|
||||||
type MatrixEvent,
|
type MatrixEvent,
|
||||||
|
NotificationCountType,
|
||||||
RelationType,
|
RelationType,
|
||||||
type Room,
|
type Room,
|
||||||
|
RoomEvent,
|
||||||
type Thread,
|
type Thread,
|
||||||
ThreadEvent,
|
ThreadEvent,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
|
|
@ -17,6 +19,7 @@ import { Time } from '../../components/message';
|
||||||
import { getChannelsThreadPath } from '../../pages/pathUtils';
|
import { getChannelsThreadPath } from '../../pages/pathUtils';
|
||||||
import { getMemberDisplayName } from '../../utils/room';
|
import { getMemberDisplayName } from '../../utils/room';
|
||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
|
import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig';
|
||||||
|
|
||||||
export type ThreadSummaryCardProps = {
|
export type ThreadSummaryCardProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -94,6 +97,33 @@ export const ThreadSummaryCard = as<'button', ThreadSummaryCardProps>(
|
||||||
};
|
};
|
||||||
}, [thread]);
|
}, [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
|
const bundled = rootId
|
||||||
? rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread)
|
? rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
@ -123,8 +153,32 @@ export const ThreadSummaryCard = as<'button', ThreadSummaryCardProps>(
|
||||||
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
|
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
|
||||||
: null;
|
: 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 countLabel = t('Room.thread_summary_count', { count });
|
||||||
const ariaParts = [t('Room.thread_summary_open_thread'), countLabel];
|
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) {
|
if (senderDisplayName) {
|
||||||
ariaParts.push(t('Room.thread_summary_last_reply_by', { name: senderDisplayName }));
|
ariaParts.push(t('Room.thread_summary_last_reply_by', { name: senderDisplayName }));
|
||||||
}
|
}
|
||||||
|
|
@ -143,9 +197,10 @@ export const ThreadSummaryCard = as<'button', ThreadSummaryCardProps>(
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
data-event-id={rootId}
|
data-event-id={rootId}
|
||||||
|
data-unread-state={dotState}
|
||||||
{...props}
|
{...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">
|
<Text className={css.Count} as="span" size="T200">
|
||||||
<b>{countLabel}</b>
|
<b>{countLabel}</b>
|
||||||
</Text>
|
</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 = {
|
export type ClientConfig = {
|
||||||
defaultHomeserver?: number;
|
defaultHomeserver?: number;
|
||||||
homeserverList?: string[];
|
homeserverList?: string[];
|
||||||
|
|
@ -43,8 +52,13 @@ export type ClientConfig = {
|
||||||
push?: PushConfig;
|
push?: PushConfig;
|
||||||
|
|
||||||
bots?: BotConfig[];
|
bots?: BotConfig[];
|
||||||
|
|
||||||
|
channels?: ChannelsConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isUnreadThreadingEnabled = (clientConfig: ClientConfig): boolean =>
|
||||||
|
clientConfig.channels?.unreadThreading !== false;
|
||||||
|
|
||||||
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
||||||
|
|
||||||
export const ClientConfigProvider = ClientConfigContext.Provider;
|
export const ClientConfigProvider = ClientConfigContext.Provider;
|
||||||
|
|
@ -55,6 +69,15 @@ export function useClientConfig(): ClientConfig {
|
||||||
return config;
|
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 =>
|
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
|
||||||
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
|
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,29 @@ import { TYPING_TIMEOUT_MS } from '../state/typingMembers';
|
||||||
|
|
||||||
type TypingStatusUpdater = (typing: boolean) => void;
|
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 statusSentTsRef = useRef<number>(0);
|
||||||
|
|
||||||
const sendTypingStatus: TypingStatusUpdater = useMemo(() => {
|
const sendTypingStatus: TypingStatusUpdater = useMemo(() => {
|
||||||
statusSentTsRef.current = 0;
|
statusSentTsRef.current = 0;
|
||||||
|
if (disabled) {
|
||||||
|
return () => {
|
||||||
|
/* noop: typing leaks across thread/main boundary by spec */
|
||||||
|
};
|
||||||
|
}
|
||||||
return (typing) => {
|
return (typing) => {
|
||||||
if (typing) {
|
if (typing) {
|
||||||
if (Date.now() - statusSentTsRef.current < TYPING_TIMEOUT_MS) {
|
if (Date.now() - statusSentTsRef.current < TYPING_TIMEOUT_MS) {
|
||||||
|
|
@ -35,7 +53,7 @@ export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): Typing
|
||||||
}
|
}
|
||||||
statusSentTsRef.current = 0;
|
statusSentTsRef.current = 0;
|
||||||
};
|
};
|
||||||
}, [mx, roomId]);
|
}, [mx, roomId, disabled]);
|
||||||
|
|
||||||
return sendTypingStatus;
|
return sendTypingStatus;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,22 @@ import { MatrixClient } from 'matrix-js-sdk';
|
||||||
import { allInvitesAtom, useBindAllInvitesAtom } from '../room-list/inviteList';
|
import { allInvitesAtom, useBindAllInvitesAtom } from '../room-list/inviteList';
|
||||||
import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
|
import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
|
||||||
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
|
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 { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
||||||
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
||||||
import { useAutoDirectSync } from '../../hooks/useAutoDirectSync';
|
import { useAutoDirectSync } from '../../hooks/useAutoDirectSync';
|
||||||
|
import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig';
|
||||||
|
|
||||||
export const useBindAtoms = (mx: MatrixClient) => {
|
export const useBindAtoms = (mx: MatrixClient) => {
|
||||||
|
const unreadThreadingEnabled = useUnreadThreadingEnabled();
|
||||||
|
|
||||||
useBindMDirectAtom(mx, mDirectAtom);
|
useBindMDirectAtom(mx, mDirectAtom);
|
||||||
useAutoDirectSync(mx);
|
useAutoDirectSync(mx);
|
||||||
useBindAllInvitesAtom(mx, allInvitesAtom);
|
useBindAllInvitesAtom(mx, allInvitesAtom);
|
||||||
useBindAllRoomsAtom(mx, allRoomsAtom);
|
useBindAllRoomsAtom(mx, allRoomsAtom);
|
||||||
useBindRoomToParentsAtom(mx, roomToParentsAtom);
|
useBindRoomToParentsAtom(mx, roomToParentsAtom);
|
||||||
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
useBindRoomToUnreadAtom(mx, roomToUnreadAtom, unreadThreadingEnabled);
|
||||||
|
|
||||||
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,14 @@
|
||||||
import produce from 'immer';
|
import produce from 'immer';
|
||||||
import { atom, useSetAtom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
import {
|
import { RoomToUnread, UnreadInfo, Unread } from '../../../types/matrix/room';
|
||||||
IRoomTimelineData,
|
import { getAllParents } from '../../utils/room';
|
||||||
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 { roomToParentsAtom } from './roomToParents';
|
import { roomToParentsAtom } from './roomToParents';
|
||||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
|
||||||
import { useSyncState } from '../../hooks/useSyncState';
|
// Pure reducer + helpers for the room → unread aggregate. The listener
|
||||||
import { useRoomsNotificationPreferencesContext } from '../../hooks/useRoomsNotificationPreferences';
|
// 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 =
|
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) {
|
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
const latestEvent = pickLatestNonPending(room.getLiveTimeline().getEvents());
|
||||||
const timeline = room.getLiveTimeline().getEvents();
|
if (!latestEvent) return;
|
||||||
const readEventId = room.getEventReadUpTo(mx.getUserId()!);
|
await mx.sendReadReceipt(latestEvent, receiptType(privateReceipt));
|
||||||
|
}
|
||||||
const getLatestValidEvent = () => {
|
|
||||||
for (let i = timeline.length - 1; i >= 0; i -= 1) {
|
// Thread-scoped variant. SDK auto-attaches `thread_id: <rootId>` via
|
||||||
const latestEvent = timeline[i];
|
// `threadIdForReceipt(event)` (matrix-js-sdk client.js:2655).
|
||||||
if (latestEvent.getId() === readEventId) return null;
|
export async function markAsThreadRead(
|
||||||
if (!latestEvent.isSending()) return latestEvent;
|
mx: MatrixClient,
|
||||||
}
|
thread: Thread,
|
||||||
return null;
|
privateReceipt: boolean
|
||||||
};
|
) {
|
||||||
if (timeline.length === 0) return;
|
if (!mx.getUserId()) return;
|
||||||
const latestEvent = getLatestValidEvent();
|
const latestEvent = pickLatestNonPending(thread.liveTimeline.getEvents());
|
||||||
if (latestEvent === null) return;
|
if (!latestEvent) return;
|
||||||
|
await mx.sendReadReceipt(latestEvent, receiptType(privateReceipt));
|
||||||
await mx.sendReadReceipt(
|
|
||||||
latestEvent,
|
|
||||||
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue