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; +}