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:
@@ -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<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
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<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
disabled={!unread}
|
||||
disabled={!unread && !markedUnread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</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}>
|
||||
{(handleOpen, opened, changing) => (
|
||||
<MenuItem
|
||||
@@ -493,6 +526,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
||||
</Text>
|
||||
</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
|
||||
onClick={handleInvite}
|
||||
variant="Primary"
|
||||
@@ -610,6 +654,10 @@ function RoomNavItem_({
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
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_({
|
||||
<NavItem
|
||||
variant="Background"
|
||||
radii="400"
|
||||
highlight={unread !== undefined}
|
||||
highlight={hasUnread}
|
||||
aria-selected={selected}
|
||||
data-hover={!!menuAnchor}
|
||||
onContextMenu={handleContextMenu}
|
||||
@@ -721,7 +769,7 @@ function RoomNavItem_({
|
||||
) : (
|
||||
<RoomIcon
|
||||
style={{
|
||||
opacity: unread ? config.opacity.P500 : config.opacity.P300,
|
||||
opacity: hasUnread ? config.opacity.P500 : config.opacity.P300,
|
||||
}}
|
||||
filled={selected}
|
||||
size="100"
|
||||
@@ -732,7 +780,7 @@ function RoomNavItem_({
|
||||
</Avatar>
|
||||
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
||||
<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}
|
||||
</Text>
|
||||
{hasLocalName && (
|
||||
@@ -773,7 +821,7 @@ function RoomNavItem_({
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
||||
{!optionsVisible && !hasUnread && !selected && typingMember.length > 0 && (
|
||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
<TypingIndicator size="300" disableAnimation />
|
||||
</Badge>
|
||||
@@ -783,6 +831,11 @@ function RoomNavItem_({
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
</UnreadBadgeCenter>
|
||||
)}
|
||||
{!optionsVisible && !unread && markedUnread && (
|
||||
<UnreadBadgeCenter>
|
||||
<UnreadBadge highlight={false} count={0} />
|
||||
</UnreadBadgeCenter>
|
||||
)}
|
||||
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
||||
<Icon
|
||||
size="50"
|
||||
|
||||
Reference in New Issue
Block a user