diff --git a/public/locales/en.json b/public/locales/en.json index 1342eb89..da3dd0fd 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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.", diff --git a/public/locales/ru.json b/public/locales/ru.json index 7c3eecbd..24ced785 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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": "Начало переписки.", diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 67a813f6..f075ba5b 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -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] ) ); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index d1055d33..2bbf49fe 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -186,7 +186,11 @@ export const RoomInput = forwardRef( const [autocompleteQuery, setAutocompleteQuery] = useState>(); - 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[]) => { diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 44657c94..857d13d3 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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] ), diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index c5011bf7..8548cc62 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -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 diff --git a/src/app/features/room/ThreadSummaryCard.css.ts b/src/app/features/room/ThreadSummaryCard.css.ts index a63332d0..029defa2 100644 --- a/src/app/features/room/ThreadSummaryCard.css.ts +++ b/src/app/features/room/ThreadSummaryCard.css.ts @@ -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({ diff --git a/src/app/features/room/ThreadSummaryCard.tsx b/src/app/features/room/ThreadSummaryCard.tsx index ad866e89..e4952e15 100644 --- a/src/app/features/room/ThreadSummaryCard.tsx +++ b/src/app/features/room/ThreadSummaryCard.tsx @@ -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(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} > - + {countLabel} diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 808beb37..5d146cb4 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -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(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'; diff --git a/src/app/hooks/useTypingStatusUpdater.ts b/src/app/hooks/useTypingStatusUpdater.ts index db8ceff1..5b6862ef 100644 --- a/src/app/hooks/useTypingStatusUpdater.ts +++ b/src/app/hooks/useTypingStatusUpdater.ts @@ -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(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; }; diff --git a/src/app/state/hooks/useBindAtoms.ts b/src/app/state/hooks/useBindAtoms.ts index 98dc0944..341c418e 100644 --- a/src/app/state/hooks/useBindAtoms.ts +++ b/src/app/state/hooks/useBindAtoms.ts @@ -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); }; diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index f7ef1b79..d562b6ea 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -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 { - 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(); - - 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] - ) - ); -}; diff --git a/src/app/state/room/useBindRoomToUnreadAtom.ts b/src/app/state/room/useBindRoomToUnreadAtom.ts new file mode 100644 index 00000000..96399db0 --- /dev/null +++ b/src/app/state/room/useBindRoomToUnreadAtom.ts @@ -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(); + + 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 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] + ) + ); +}; diff --git a/src/app/utils/notifications.ts b/src/app/utils/notifications.ts index a23bd1a4..7c8a4197 100644 --- a/src/app/utils/notifications.ts +++ b/src/app/utils/notifications.ts @@ -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: ` 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)); }