diff --git a/LOTUS_TESTING.md b/LOTUS_TESTING.md index 7ec55d176..b126b3a40 100644 --- a/LOTUS_TESTING.md +++ b/LOTUS_TESTING.md @@ -675,6 +675,8 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, ## Outstanding verification backlog +**Mark as Unread + Low Priority (MSC2867 / m.lowpriority, 2026-07):** Right-click a room in the sidebar → **Mark as Unread** puts a dot on the row (bold name) even with no new messages; opening/reading the room clears it, and it syncs to another device. **Mark as Read** on a marked room clears it too. Right-click → **Add to Low Priority** moves the room into a collapsed "Low Priority" category at the bottom of the room list (and removes it from Favorites if it was there, and vice-versa); **Remove from Low Priority** returns it to Rooms. + **Windows rich toast (D6, 2026-07 — desktop/Windows build only):** get a message notification while the desktop app is backgrounded → the toast is attributed to **Lotus Chat** (not "PowerShell"/generic) and shows an inline **reply box + Send**; typing a reply + Send **posts it to that room**; clicking the toast body **opens the room**. Previously these silently fell back to a plain toast (no reply/click). If it still falls back, check that a `Lotus Chat.lnk` exists in the Start-Menu Programs folder. **Invite QR is now generated LOCALLY (2026-07):** Room settings → Share Room → the QR code renders (a black-on-white SVG in a white box) with **no network request** to `api.qrserver.com` (check DevTools Network — there should be no external QR fetch, and it should work offline / behind strict CSP). **Scan it** with a phone camera / Matrix app → it opens the correct `matrix.to` room-invite link. (`api.qrserver.com` was removed from the prod CSP img-src, so a regression would make the QR blank rather than silently phone home.) diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index 39ddd63bc..07f9bc733 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -95,10 +95,10 @@ Observed live in prod 2026-06-30 during a 2-person **Element Call** (E2EE). Thes Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audited 2026-07 against the codebase — almost everything else is built: pinning, stickers+picker, room directory, mutual rooms MSC2666, blurhash, key backup/recovery/SSSS, SAS verification, ignore list, invite spam-filter, voice messages, polls, threads, spaces, OIDC, extended profiles, delayed events, authed media). Build each **fully** — spec-correct events, native-Cinny folds UI, tests. Order = clean wins first. -**Phase A (small, room-tag/account-data + `RoomNavItem` menu + room-list category):** +**Phase A ✅ (2026-07, gate-green 683 tests):** -- [ ] **Mark as Unread — MSC2867 `m.marked_unread`.** Room account data `{ unread: true }` (+ unstable `com.famedly.marked_unread`) via `mx.setRoomAccountData`; clear on read. Context-menu item in `RoomNavItem` + light the existing unread dot; integrate `state/room/roomToUnread.ts`. -- [ ] **Low Priority rooms — `m.lowpriority` tag.** Mirror the favourite impl (`RoomNavItem.tsx:331-337` `setRoomTag/deleteRoomTag` + the favourites category in `home/Home.tsx`): context-menu toggle + a collapsed "Low Priority" category sorted to the bottom, excluded from normal unread nudging. +- [x] **Mark as Unread — MSC2867 `m.marked_unread`.** Room account data `{ unread: true }` (+ unstable `com.famedly.marked_unread`) via `mx.setRoomAccountData`; clear on read. Context-menu item in `RoomNavItem` + light the existing unread dot; integrate `state/room/roomToUnread.ts`. +- [x] **Low Priority rooms — `m.lowpriority` tag.** Mirror the favourite impl (`RoomNavItem.tsx:331-337` `setRoomTag/deleteRoomTag` + the favourites category in `home/Home.tsx`): context-menu toggle + a collapsed "Low Priority" category sorted to the bottom, excluded from normal unread nudging. **Phase B:** diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 4b72999e3..37a3f74e4 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -38,6 +38,7 @@ import { nameInitials } from '../../utils/common'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomUnread } from '../../state/hooks/unread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; +import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread'; import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels'; import { markAsRead } from '../../utils/notifications'; import { UseStateProvider } from '../../components/UseStateProvider'; @@ -329,18 +330,39 @@ const RoomNavItemMenu = forwardRef( const isServerNotice = room.getType() === 'm.server_notice'; const isFavorite = !!room.tags?.['m.favourite']; + const isLowPriority = !!room.tags?.['m.lowpriority']; const handleToggleFavorite = () => { if (isFavorite) { mx.deleteRoomTag(room.roomId, 'm.favourite'); } else { + // Favourite and low-priority are mutually exclusive. + if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority'); mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 }); } requestClose(); }; + const handleToggleLowPriority = () => { + if (isLowPriority) { + mx.deleteRoomTag(room.roomId, 'm.lowpriority'); + } else { + if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite'); + mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 }); + } + requestClose(); + }; + + const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId); + const handleMarkAsRead = () => { markAsRead(mx, room.roomId, hideActivity); + if (markedUnread) setMarkedUnread(mx, room.roomId, false).catch(() => undefined); + requestClose(); + }; + + const handleMarkAsUnread = () => { + setMarkedUnread(mx, room.roomId, true).catch(() => undefined); requestClose(); }; @@ -393,12 +415,23 @@ const RoomNavItemMenu = forwardRef( size="300" after={} radii="300" - disabled={!unread} + disabled={!unread && !markedUnread} > Mark as Read + } + radii="300" + disabled={!!unread || markedUnread} + > + + Mark as Unread + + {(handleOpen, opened, changing) => ( ( {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} + } + radii="300" + aria-pressed={isLowPriority} + > + + {isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'} + + (); const [renameDialog, setRenameDialog] = useState(false); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); + // MSC2867: an explicit "mark as unread" lights the row even with no unread + // count. `hasUnread` drives the bold name / icon emphasis below. + const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId); + const hasUnread = !!unread || markedUnread; const typingMember = useRoomTypingMember(room.roomId).filter( (receipt) => receipt.userId !== mx.getUserId(), ); @@ -692,7 +740,7 @@ function RoomNavItem_({ - + {roomName} {hasLocalName && ( @@ -773,7 +821,7 @@ function RoomNavItem_({ )} - {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( + {!optionsVisible && !hasUnread && !selected && typingMember.length > 0 && ( @@ -783,6 +831,11 @@ function RoomNavItem_({ 0} count={unread.total} /> )} + {!optionsVisible && !unread && markedUnread && ( + + + + )} {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( (); - const { favoriteRooms, otherRooms } = useMemo(() => { + const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => { const favs: string[] = []; + const low: string[] = []; const others: string[] = []; rooms.forEach((rId) => { const room = mx.getRoom(rId); if (room?.tags?.['m.favourite']) { favs.push(rId); + } else if (room?.tags?.['m.lowpriority']) { + low.push(rId); } else { others.push(rId); } }); - return { favoriteRooms: favs, otherRooms: others }; + return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others }; }, [mx, rooms]); const sortedFavoriteRooms = useMemo(() => { @@ -297,6 +301,28 @@ export function Home() { }); }, [mx, sortedFavoriteRooms, filterQuery]); + const sortedLowPriorityRooms = useMemo(() => { + const isClosed = closedCategories.has(LOW_PRIORITY_CATEGORY_ID); + const items = Array.from(lowPriorityRooms).sort( + isClosed ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx), + ); + if (isClosed) { + return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId); + } + return items; + }, [mx, lowPriorityRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]); + + const filteredLowPriorityRooms = useMemo(() => { + if (!filterQuery.trim()) return sortedLowPriorityRooms; + const query = filterQuery.toLowerCase(); + const localNames = getLocalRoomNamesContent(mx); + return sortedLowPriorityRooms.filter((rId) => { + const localName = localNames.rooms[rId]; + const matrixName = mx.getRoom(rId)?.name ?? ''; + return (localName ?? matrixName).toLowerCase().includes(query); + }); + }, [mx, sortedLowPriorityRooms, filterQuery]); + const sortedRooms = useMemo(() => { const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID); let comparator: (a: string, b: string) => number; @@ -349,6 +375,13 @@ export function Home() { overscan: 10, }); + const lowVirtualizer = useVirtualizer({ + count: filteredLowPriorityRooms.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 38, + overscan: 10, + }); + const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => closedCategories.has(categoryId), ); @@ -638,6 +671,43 @@ export function Home() { })} + {lowPriorityRooms.length > 0 && ( + + + + Low Priority + + +
+ {lowVirtualizer.getVirtualItems().map((vItem) => { + const roomId = filteredLowPriorityRooms[vItem.index]; + const room = mx.getRoom(roomId); + if (!room) return null; + return ( + + + + ); + })} +
+
+ )} )} diff --git a/src/app/state/hooks/useBindAtoms.ts b/src/app/state/hooks/useBindAtoms.ts index 703c558be..61ddcd2aa 100644 --- a/src/app/state/hooks/useBindAtoms.ts +++ b/src/app/state/hooks/useBindAtoms.ts @@ -3,6 +3,7 @@ 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 { markedUnreadAtom, useBindMarkedUnreadAtom } from '../room/markedUnread'; import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents'; import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers'; import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications'; @@ -14,6 +15,7 @@ export const useBindAtoms = (mx: MatrixClient) => { useBindRoomToParentsAtom(mx, roomToParentsAtom); useBindThreadNotificationsAtom(mx, threadNotificationsAtom); useBindRoomToUnreadAtom(mx, roomToUnreadAtom); + useBindMarkedUnreadAtom(mx, markedUnreadAtom); useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom); }; diff --git a/src/app/state/room/markedUnread.test.ts b/src/app/state/room/markedUnread.test.ts new file mode 100644 index 000000000..c1dec552e --- /dev/null +++ b/src/app/state/room/markedUnread.test.ts @@ -0,0 +1,65 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { MatrixEvent } from 'matrix-js-sdk'; +import { receiptIsMine, setMarkedUnread } from './markedUnread'; + +// MSC2867 mark-as-unread: reading a room (our own receipt) clears the flag, so +// `receiptIsMine` must detect only OUR receipt and ignore others'. And a write +// must land on BOTH the stable `m.marked_unread` and the unstable +// `com.famedly.marked_unread` key so it round-trips across servers/clients. + +const ME = '@me:server'; +const OTHER = '@friend:server'; + +const receiptEvent = (content: object): MatrixEvent => + ({ getContent: () => content }) as MatrixEvent; + +test('receiptIsMine: true when the receipt content carries our user id', () => { + const event = receiptEvent({ + $abc: { 'm.read': { [ME]: { ts: 1 } } }, + }); + assert.equal(receiptIsMine(event, ME), true); +}); + +test('receiptIsMine: false when only another user has a receipt', () => { + const event = receiptEvent({ + $abc: { 'm.read': { [OTHER]: { ts: 1 } } }, + }); + assert.equal(receiptIsMine(event, ME), false); +}); + +test('receiptIsMine: tolerates empty / malformed content', () => { + assert.equal(receiptIsMine(receiptEvent({}), ME), false); + assert.equal(receiptIsMine(receiptEvent({ $x: {} }), ME), false); +}); + +test('setMarkedUnread writes both the stable and unstable keys with the flag', async () => { + const calls: Array<{ type: string; content: unknown }> = []; + const mx = { + setRoomAccountData: (_roomId: string, type: string, content: unknown) => { + calls.push({ type, content }); + return Promise.resolve(); + }, + } as any; + + await setMarkedUnread(mx, '!room:server', true); + + const types = calls.map((c) => c.type).sort(); + assert.deepEqual(types, ['com.famedly.marked_unread', 'm.marked_unread']); + assert.ok(calls.every((c) => (c.content as { unread: boolean }).unread === true)); +}); + +test('setMarkedUnread(false) clears both keys and does not reject if the unstable write fails', async () => { + const seen: string[] = []; + const mx = { + setRoomAccountData: (_roomId: string, type: string) => { + seen.push(type); + // Simulate an older server rejecting the unstable key — must not reject. + if (type === 'com.famedly.marked_unread') return Promise.reject(new Error('unknown type')); + return Promise.resolve(); + }, + } as any; + + await assert.doesNotReject(() => setMarkedUnread(mx, '!room:server', false)); + assert.ok(seen.includes('m.marked_unread')); +}); diff --git a/src/app/state/room/markedUnread.ts b/src/app/state/room/markedUnread.ts new file mode 100644 index 000000000..a4ec897f6 --- /dev/null +++ b/src/app/state/room/markedUnread.ts @@ -0,0 +1,97 @@ +import { atom, useSetAtom } from 'jotai'; +import { MatrixClient, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; +import { useEffect } from 'react'; +import { AccountDataEvent } from '../../../types/matrix/accountData'; + +// MSC2867 — "mark a room as unread". A per-room account-data flag `{ unread }`. +// Stable type `m.marked_unread`; servers/clients predating the stabilization use +// the unstable `com.famedly.marked_unread`. We read either and write both so the +// flag round-trips across the ecosystem. +const UNSTABLE_MARKED_UNREAD = 'com.famedly.marked_unread'; + +const readMarkedUnread = (room: Room): boolean => { + const stable = room.getAccountData(AccountDataEvent.MarkedUnread)?.getContent()?.unread; + if (typeof stable === 'boolean') return stable; + return room.getAccountData(UNSTABLE_MARKED_UNREAD)?.getContent()?.unread === true; +}; + +/** Set of room ids the user has explicitly marked as unread. */ +export const markedUnreadAtom = atom>(new Set()); + +/** Write (or clear) the marked-unread flag on both the stable + unstable keys. */ +export const setMarkedUnread = ( + mx: MatrixClient, + roomId: string, + unread: boolean, +): Promise => + Promise.all([ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mx.setRoomAccountData(roomId, AccountDataEvent.MarkedUnread as any, { unread }), + // Best-effort mirror for older servers; never fail the primary write on it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mx.setRoomAccountData(roomId, UNSTABLE_MARKED_UNREAD as any, { unread }).catch(() => undefined), + ]); + +export const receiptIsMine = (event: MatrixEvent, userId: string): boolean => { + const content = event.getContent(); + return Object.keys(content).some((eventId) => + Object.keys(content[eventId] ?? {}).some( + (receiptType) => content[eventId][receiptType]?.[userId], + ), + ); +}; + +export const useBindMarkedUnreadAtom = (mx: MatrixClient, anAtom: typeof markedUnreadAtom) => { + const setAtom = useSetAtom(anAtom); + + useEffect(() => { + const seed = new Set(); + mx.getRooms().forEach((room) => { + if (readMarkedUnread(room)) seed.add(room.roomId); + }); + setAtom(seed); + + const syncRoom = (room: Room) => { + const marked = readMarkedUnread(room); + setAtom((prev) => { + if (marked === prev.has(room.roomId)) return prev; + const next = new Set(prev); + if (marked) next.add(room.roomId); + else next.delete(room.roomId); + return next; + }); + }; + + const onAccountData: RoomEventHandlerMap[RoomEvent.AccountData] = (_event, room) => { + syncRoom(room); + }; + // Reading a room clears its marked-unread flag (MSC2867): when our own read + // receipt lands for a room that's currently marked, clear it. + const onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, room) => { + const myId = mx.getUserId(); + if (!myId || !readMarkedUnread(room)) return; + if (receiptIsMine(event, myId)) { + setMarkedUnread(mx, room.roomId, false).catch(() => undefined); + } + }; + const onMembership: RoomEventHandlerMap[RoomEvent.MyMembership] = (room) => { + if (room.getMyMembership() !== 'join') { + setAtom((prev) => { + if (!prev.has(room.roomId)) return prev; + const next = new Set(prev); + next.delete(room.roomId); + return next; + }); + } + }; + + mx.on(RoomEvent.AccountData, onAccountData); + mx.on(RoomEvent.Receipt, onReceipt); + mx.on(RoomEvent.MyMembership, onMembership); + return () => { + mx.removeListener(RoomEvent.AccountData, onAccountData); + mx.removeListener(RoomEvent.Receipt, onReceipt); + mx.removeListener(RoomEvent.MyMembership, onMembership); + }; + }, [mx, setAtom]); +}; diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 32d501622..484b131a6 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -2,6 +2,8 @@ export enum AccountDataEvent { PushRules = 'm.push_rules', Direct = 'm.direct', IgnoredUserList = 'm.ignored_user_list', + // [MSC2867] Per-room "mark as unread" flag (room account data). + MarkedUnread = 'm.marked_unread', CinnySpaces = 'in.cinny.spaces',