From fdec3ed7f2816cecc79e862b83c58e6081f55a09 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 23 Sep 2026 15:25:53 -0400 Subject: [PATCH] feat(threads): "Mark all read" in the threads list (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "Mark all read" chip appears in the Threads panel header whenever any thread in the room has unread replies, and sends one threaded receipt per unread thread (never the root — a root lives in the main timeline and a receipt there would drag the MAIN read marker backwards, the P6 regression). Honours the private-receipt settings. Verified: two threads with unread replies → the chip appears, the rows say "unread" in their labels; after clicking, no unread rows and the chip is gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../features/room/thread/ThreadsListPanel.tsx | 49 ++++++++++++++++++- src/app/utils/markThreadsRead.ts | 29 +++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/app/utils/markThreadsRead.ts diff --git a/src/app/features/room/thread/ThreadsListPanel.tsx b/src/app/features/room/thread/ThreadsListPanel.tsx index 5447b2237..c1e5c84f2 100644 --- a/src/app/features/room/thread/ThreadsListPanel.tsx +++ b/src/app/features/room/thread/ThreadsListPanel.tsx @@ -1,8 +1,20 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { atomWithStorage, createJSONStorage } from 'jotai/utils'; import { NotificationCountType, Room, Thread } from 'matrix-js-sdk'; -import { Avatar, Box, Button, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds'; +import { + Avatar, + Box, + Button, + Chip, + Header, + Icon, + IconButton, + Icons, + Scroll, + Text, + config, +} from 'folds'; import classNames from 'classnames'; import { useVirtualizer } from '@tanstack/react-virtual'; import * as css from './ThreadsListPanel.css'; @@ -14,6 +26,10 @@ import { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge import { useMemberAvatar } from '../../../hooks/useMemberAvatar'; import { trimReplyFromBody } from '../../../utils/room'; import { scaleSystemEmoji } from '../../../plugins/react-custom-html-parser'; +import { markAllThreadsRead } from '../../../utils/markThreadsRead'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; import { threadNotificationsAtom } from '../../../state/threadNotifications'; import { getMutedThreads } from '../../../utils/threadNotifications'; import { nameInitials } from '../../../utils/common'; @@ -259,6 +275,22 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan }; }, [threads, room, filter, sort, threadNotifications]); + // [Gitea #165] "Mark all read" — one threaded receipt per unread thread. + const mx = useMatrixClient(); + const [marking, setMarking] = useState(false); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts'); + const totalUnread = useMemo( + () => [...unreadById.values()].reduce((n, c) => n + c, 0), + [unreadById], + ); + const handleMarkAllRead = useCallback(() => { + setMarking(true); + markAllThreadsRead(mx, room, hideActivity || privateReadReceipts).finally(() => + setMarking(false), + ); + }, [mx, room, hideActivity, privateReadReceipts]); + const scrollRef = useRef(null) as React.RefObject; const virtualizer = useVirtualizer({ count: visible.length, @@ -291,6 +323,19 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan {room.name} + {totalUnread > 0 && ( + } + > + Mark all read + + )} diff --git a/src/app/utils/markThreadsRead.ts b/src/app/utils/markThreadsRead.ts new file mode 100644 index 000000000..7ff3fce16 --- /dev/null +++ b/src/app/utils/markThreadsRead.ts @@ -0,0 +1,29 @@ +import { MatrixClient, NotificationCountType, ReceiptType, Room } from 'matrix-js-sdk'; + +/** + * [Gitea #165] Send a threaded read receipt for every thread in the room that + * has unread replies. Mirrors the per-thread half of `markAsRead`, including + * its rule: never fall back to the thread root (a root lives in the main + * timeline, so a receipt there would move the MAIN read marker backwards). + * Returns how many threads were marked. + */ +export async function markAllThreadsRead( + mx: MatrixClient, + room: Room, + privateReceipt: boolean, +): Promise { + const receiptType = privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read; + const sends = room + .getThreads() + .map((thread) => { + const unread = + room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0; + if (unread <= 0) return undefined; + const lastReply = thread.lastReply(); + if (!lastReply || lastReply.isSending()) return undefined; + return mx.sendReadReceipt(lastReply, receiptType, false).catch(() => undefined); + }) + .filter((p): p is NonNullable => !!p); + await Promise.all(sends); + return sends.length; +}