feat(rooms): Mark as Unread (MSC2867) + Low Priority rooms
Two Matrix protocol gaps (Phase A), gate-green (683 tests): - Mark as Unread: m.marked_unread room account data (+ com.famedly.marked_unread fallback), a new markedUnreadAtom binder that seeds from account data and clears on our own read receipt (MSC2867). RoomNavItem gains Mark as Unread / Read menu items and lights the row dot for a marked room. Tested. - Low Priority: m.lowpriority room tag mirroring favourites — a context-menu toggle (mutually exclusive with Favorite) and a collapsed Low Priority category sorted to the bottom of the Home room list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -675,6 +675,8 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view,
|
|||||||
|
|
||||||
## Outstanding verification backlog
|
## 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.
|
**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.)
|
**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.)
|
||||||
|
|||||||
+3
-3
@@ -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.
|
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`.
|
- [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`.
|
||||||
- [ ] **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] **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:**
|
**Phase B:**
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { nameInitials } from '../../utils/common';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoomUnread } from '../../state/hooks/unread';
|
import { useRoomUnread } from '../../state/hooks/unread';
|
||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
|
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
|
||||||
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
|
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
import { markAsRead } from '../../utils/notifications';
|
import { markAsRead } from '../../utils/notifications';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
@@ -329,18 +330,39 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
const isServerNotice = room.getType() === 'm.server_notice';
|
const isServerNotice = room.getType() === 'm.server_notice';
|
||||||
|
|
||||||
const isFavorite = !!room.tags?.['m.favourite'];
|
const isFavorite = !!room.tags?.['m.favourite'];
|
||||||
|
const isLowPriority = !!room.tags?.['m.lowpriority'];
|
||||||
|
|
||||||
const handleToggleFavorite = () => {
|
const handleToggleFavorite = () => {
|
||||||
if (isFavorite) {
|
if (isFavorite) {
|
||||||
mx.deleteRoomTag(room.roomId, 'm.favourite');
|
mx.deleteRoomTag(room.roomId, 'm.favourite');
|
||||||
} else {
|
} 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 });
|
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 });
|
||||||
}
|
}
|
||||||
requestClose();
|
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 = () => {
|
const handleMarkAsRead = () => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
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();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -393,12 +415,23 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
size="300"
|
size="300"
|
||||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||||
radii="300"
|
radii="300"
|
||||||
disabled={!unread}
|
disabled={!unread && !markedUnread}
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
Mark as Read
|
Mark as Read
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleMarkAsUnread}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.MessageUnread} />}
|
||||||
|
radii="300"
|
||||||
|
disabled={!!unread || markedUnread}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Mark as Unread
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
||||||
{(handleOpen, opened, changing) => (
|
{(handleOpen, opened, changing) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -493,6 +526,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleToggleLowPriority}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.ChevronBottom} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={isLowPriority}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
{isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleInvite}
|
onClick={handleInvite}
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
@@ -610,6 +654,10 @@ function RoomNavItem_({
|
|||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [renameDialog, setRenameDialog] = useState(false);
|
const [renameDialog, setRenameDialog] = useState(false);
|
||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
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(
|
const typingMember = useRoomTypingMember(room.roomId).filter(
|
||||||
(receipt) => receipt.userId !== mx.getUserId(),
|
(receipt) => receipt.userId !== mx.getUserId(),
|
||||||
);
|
);
|
||||||
@@ -692,7 +740,7 @@ function RoomNavItem_({
|
|||||||
<NavItem
|
<NavItem
|
||||||
variant="Background"
|
variant="Background"
|
||||||
radii="400"
|
radii="400"
|
||||||
highlight={unread !== undefined}
|
highlight={hasUnread}
|
||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
data-hover={!!menuAnchor}
|
data-hover={!!menuAnchor}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
@@ -721,7 +769,7 @@ function RoomNavItem_({
|
|||||||
) : (
|
) : (
|
||||||
<RoomIcon
|
<RoomIcon
|
||||||
style={{
|
style={{
|
||||||
opacity: unread ? config.opacity.P500 : config.opacity.P300,
|
opacity: hasUnread ? config.opacity.P500 : config.opacity.P300,
|
||||||
}}
|
}}
|
||||||
filled={selected}
|
filled={selected}
|
||||||
size="100"
|
size="100"
|
||||||
@@ -732,7 +780,7 @@ function RoomNavItem_({
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="100">
|
<Box as="span" grow="Yes" alignItems="Center" gap="100">
|
||||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
<Text priority={hasUnread ? '500' : '300'} as="span" size="Inherit" truncate>
|
||||||
{roomName}
|
{roomName}
|
||||||
</Text>
|
</Text>
|
||||||
{hasLocalName && (
|
{hasLocalName && (
|
||||||
@@ -773,7 +821,7 @@ function RoomNavItem_({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
{!optionsVisible && !hasUnread && !selected && typingMember.length > 0 && (
|
||||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||||
<TypingIndicator size="300" disableAnimation />
|
<TypingIndicator size="300" disableAnimation />
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -783,6 +831,11 @@ function RoomNavItem_({
|
|||||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||||
</UnreadBadgeCenter>
|
</UnreadBadgeCenter>
|
||||||
)}
|
)}
|
||||||
|
{!optionsVisible && !unread && markedUnread && (
|
||||||
|
<UnreadBadgeCenter>
|
||||||
|
<UnreadBadge highlight={false} count={0} />
|
||||||
|
</UnreadBadgeCenter>
|
||||||
|
)}
|
||||||
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
||||||
<Icon
|
<Icon
|
||||||
size="50"
|
size="50"
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ const factoryRoomIdByUnread =
|
|||||||
|
|
||||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
||||||
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
||||||
|
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
useNavToActivePathMapper('home');
|
useNavToActivePathMapper('home');
|
||||||
@@ -261,18 +262,21 @@ export function Home() {
|
|||||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||||
const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>();
|
const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>();
|
||||||
|
|
||||||
const { favoriteRooms, otherRooms } = useMemo(() => {
|
const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => {
|
||||||
const favs: string[] = [];
|
const favs: string[] = [];
|
||||||
|
const low: string[] = [];
|
||||||
const others: string[] = [];
|
const others: string[] = [];
|
||||||
rooms.forEach((rId) => {
|
rooms.forEach((rId) => {
|
||||||
const room = mx.getRoom(rId);
|
const room = mx.getRoom(rId);
|
||||||
if (room?.tags?.['m.favourite']) {
|
if (room?.tags?.['m.favourite']) {
|
||||||
favs.push(rId);
|
favs.push(rId);
|
||||||
|
} else if (room?.tags?.['m.lowpriority']) {
|
||||||
|
low.push(rId);
|
||||||
} else {
|
} else {
|
||||||
others.push(rId);
|
others.push(rId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { favoriteRooms: favs, otherRooms: others };
|
return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others };
|
||||||
}, [mx, rooms]);
|
}, [mx, rooms]);
|
||||||
|
|
||||||
const sortedFavoriteRooms = useMemo(() => {
|
const sortedFavoriteRooms = useMemo(() => {
|
||||||
@@ -297,6 +301,28 @@ export function Home() {
|
|||||||
});
|
});
|
||||||
}, [mx, sortedFavoriteRooms, filterQuery]);
|
}, [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 sortedRooms = useMemo(() => {
|
||||||
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
|
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
|
||||||
let comparator: (a: string, b: string) => number;
|
let comparator: (a: string, b: string) => number;
|
||||||
@@ -349,6 +375,13 @@ export function Home() {
|
|||||||
overscan: 10,
|
overscan: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lowVirtualizer = useVirtualizer({
|
||||||
|
count: filteredLowPriorityRooms.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 38,
|
||||||
|
overscan: 10,
|
||||||
|
});
|
||||||
|
|
||||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
||||||
closedCategories.has(categoryId),
|
closedCategories.has(categoryId),
|
||||||
);
|
);
|
||||||
@@ -638,6 +671,43 @@ export function Home() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
|
{lowPriorityRooms.length > 0 && (
|
||||||
|
<NavCategory>
|
||||||
|
<NavCategoryHeader>
|
||||||
|
<RoomNavCategoryButton
|
||||||
|
closed={closedCategories.has(LOW_PRIORITY_CATEGORY_ID)}
|
||||||
|
data-category-id={LOW_PRIORITY_CATEGORY_ID}
|
||||||
|
onClick={handleCategoryClick}
|
||||||
|
>
|
||||||
|
Low Priority
|
||||||
|
</RoomNavCategoryButton>
|
||||||
|
</NavCategoryHeader>
|
||||||
|
<div style={{ position: 'relative', height: lowVirtualizer.getTotalSize() }}>
|
||||||
|
{lowVirtualizer.getVirtualItems().map((vItem) => {
|
||||||
|
const roomId = filteredLowPriorityRooms[vItem.index];
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
return (
|
||||||
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
key={roomId}
|
||||||
|
ref={lowVirtualizer.measureElement}
|
||||||
|
>
|
||||||
|
<RoomNavItem
|
||||||
|
room={room}
|
||||||
|
selected={selectedRoomId === roomId}
|
||||||
|
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||||
|
notificationMode={getRoomNotificationMode(
|
||||||
|
notificationPreferences,
|
||||||
|
room.roomId,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</NavCategory>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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, useBindRoomToUnreadAtom } from '../room/roomToUnread';
|
||||||
|
import { markedUnreadAtom, useBindMarkedUnreadAtom } from '../room/markedUnread';
|
||||||
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
||||||
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
||||||
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
|
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
|
||||||
@@ -14,6 +15,7 @@ export const useBindAtoms = (mx: MatrixClient) => {
|
|||||||
useBindRoomToParentsAtom(mx, roomToParentsAtom);
|
useBindRoomToParentsAtom(mx, roomToParentsAtom);
|
||||||
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
|
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
|
||||||
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
||||||
|
useBindMarkedUnreadAtom(mx, markedUnreadAtom);
|
||||||
|
|
||||||
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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<Set<string>>(new Set<string>());
|
||||||
|
|
||||||
|
/** Write (or clear) the marked-unread flag on both the stable + unstable keys. */
|
||||||
|
export const setMarkedUnread = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
unread: boolean,
|
||||||
|
): Promise<unknown> =>
|
||||||
|
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<string>();
|
||||||
|
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]);
|
||||||
|
};
|
||||||
@@ -2,6 +2,8 @@ export enum AccountDataEvent {
|
|||||||
PushRules = 'm.push_rules',
|
PushRules = 'm.push_rules',
|
||||||
Direct = 'm.direct',
|
Direct = 'm.direct',
|
||||||
IgnoredUserList = 'm.ignored_user_list',
|
IgnoredUserList = 'm.ignored_user_list',
|
||||||
|
// [MSC2867] Per-room "mark as unread" flag (room account data).
|
||||||
|
MarkedUnread = 'm.marked_unread',
|
||||||
|
|
||||||
CinnySpaces = 'in.cinny.spaces',
|
CinnySpaces = 'in.cinny.spaces',
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user