Compare commits
3
Commits
5d5ae0ee70
...
39e75f4eea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39e75f4eea | ||
|
|
cb3cd30ab5 | ||
|
|
cacefb1f30 |
@@ -727,6 +727,8 @@ Redacted events display "This message has been deleted" along with the redaction
|
|||||||
- Bookmarks are stored in `io.lotus.bookmarks` account data, syncing across all devices
|
- Bookmarks are stored in `io.lotus.bookmarks` account data, syncing across all devices
|
||||||
- Maximum of 500 bookmarked entries
|
- Maximum of 500 bookmarked entries
|
||||||
- `BookmarksPanel.tsx` is a sidebar panel accessible from the navigation rail
|
- `BookmarksPanel.tsx` is a sidebar panel accessible from the navigation rail
|
||||||
|
- Live-renders edits/redactions, text search, jump-to-message, and remove
|
||||||
|
- **Sort & group**: a Newest / Oldest / By-room segmented control sorts the list; "By room" renders collapsible per-room sections (groups ordered by most-recent save). The chosen sort persists across panel opens (`cinny_bookmarks_sort_v1`). Ordering/grouping logic is pure and unit-tested in `src/app/utils/bookmarks.ts` (`bookmarks.test.ts`).
|
||||||
- Hook: `src/app/hooks/useBookmarks.ts`
|
- Hook: `src/app/hooks/useBookmarks.ts`
|
||||||
|
|
||||||
### Message Scheduling
|
### Message Scheduling
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import React, { ChangeEvent, ReactNode, useCallback, useEffect, useState } from 'react';
|
import React, {
|
||||||
|
ChangeEvent,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -16,6 +25,12 @@ import {
|
|||||||
} from 'folds';
|
} from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
||||||
|
import {
|
||||||
|
BookmarkSort,
|
||||||
|
isBookmarkSort,
|
||||||
|
sortBookmarks,
|
||||||
|
groupBookmarksByRoom,
|
||||||
|
} from '../../utils/bookmarks';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||||
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
|
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
|
||||||
@@ -41,6 +56,46 @@ function formatTimeAgo(ts: number): string {
|
|||||||
return new Date(ts).toLocaleDateString();
|
return new Date(ts).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remember the last-chosen sort across panel opens (the panel unmounts on close).
|
||||||
|
// getOnInit reads localStorage synchronously at init so the persisted sort is
|
||||||
|
// applied on the very first render (no flash from the 'newest' default).
|
||||||
|
const bookmarkSortAtom = atomWithStorage<BookmarkSort>(
|
||||||
|
'cinny_bookmarks_sort_v1',
|
||||||
|
'newest',
|
||||||
|
createJSONStorage(() => localStorage),
|
||||||
|
{ getOnInit: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const SORT_OPTIONS: { value: BookmarkSort; label: string }[] = [
|
||||||
|
{ value: 'newest', label: 'Newest' },
|
||||||
|
{ value: 'oldest', label: 'Oldest' },
|
||||||
|
{ value: 'room', label: 'By room' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Segmented sort button — mirrors MediaGallery's tab styling for house consistency.
|
||||||
|
function SortButton({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant={active ? 'Primary' : 'Secondary'}
|
||||||
|
fill={active ? 'Solid' : 'Soft'}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Text size="B300">{label}</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type BookmarkItemProps = {
|
type BookmarkItemProps = {
|
||||||
bookmark: Bookmark;
|
bookmark: Bookmark;
|
||||||
onJump: (roomId: string, eventId: string) => void;
|
onJump: (roomId: string, eventId: string) => void;
|
||||||
@@ -147,6 +202,68 @@ function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItem
|
|||||||
return <BookmarkItem bookmark={bookmark} onJump={onJump} onRemove={onRemove} preview={preview} />;
|
return <BookmarkItem bookmark={bookmark} onJump={onJump} onRemove={onRemove} preview={preview} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RoomGroupHeaderProps = {
|
||||||
|
roomId: string;
|
||||||
|
roomName: string;
|
||||||
|
count: number;
|
||||||
|
collapsed: boolean;
|
||||||
|
contentId: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collapsible section header for the "By room" grouping. Uses the live room name
|
||||||
|
// / avatar when the room is joined, falling back to the stored snapshot name.
|
||||||
|
function RoomGroupHeader({
|
||||||
|
roomId,
|
||||||
|
roomName,
|
||||||
|
count,
|
||||||
|
collapsed,
|
||||||
|
contentId,
|
||||||
|
onToggle,
|
||||||
|
}: RoomGroupHeaderProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const room = mx.getRoom(roomId) ?? undefined;
|
||||||
|
const displayRoomName = room?.name ?? roomName;
|
||||||
|
const avatarUrl = room
|
||||||
|
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
aria-controls={contentId}
|
||||||
|
// Explicit name avoids the avatar alt + visible name being announced twice,
|
||||||
|
// and gives the bare count meaning for screen readers.
|
||||||
|
aria-label={`${displayRoomName}, ${count} saved message${count !== 1 ? 's' : ''}`}
|
||||||
|
style={{ justifyContent: 'flex-start', padding: config.space.S200 }}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200" style={{ minWidth: 0 }}>
|
||||||
|
<Icon size="100" src={collapsed ? Icons.ChevronRight : Icons.ChevronBottom} />
|
||||||
|
<Avatar size="200" radii="300">
|
||||||
|
<RoomAvatar
|
||||||
|
roomId={roomId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
renderFallback={() => <Text size="H6">{nameInitials(displayRoomName)}</Text>}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<Text size="T200" truncate style={{ flexGrow: 1, fontWeight: config.fontWeight.W600 }}>
|
||||||
|
{displayRoomName}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type BookmarksPanelProps = {
|
type BookmarksPanelProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
@@ -156,6 +273,20 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
|||||||
const { bookmarks, removeBookmark } = useBookmarks();
|
const { bookmarks, removeBookmark } = useBookmarks();
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
|
const [storedSort, setSort] = useAtom(bookmarkSortAtom);
|
||||||
|
// Normalize a stale/corrupt persisted value so exactly one sort is always active.
|
||||||
|
const sort: BookmarkSort = isBookmarkSort(storedSort) ? storedSort : 'newest';
|
||||||
|
// roomIds whose group section is collapsed (only relevant in "By room" mode).
|
||||||
|
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleGroup = useCallback((roomId: string) => {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(roomId)) next.delete(roomId);
|
||||||
|
else next.add(roomId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Escape closes the panel (parity with the app's other overlays/drawers).
|
// Escape closes the panel (parity with the app's other overlays/drawers).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -182,13 +313,67 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const query = filter.trim().toLowerCase();
|
const query = filter.trim().toLowerCase();
|
||||||
const filtered: Bookmark[] =
|
const filtered: Bookmark[] = useMemo(
|
||||||
|
() =>
|
||||||
query.length === 0
|
query.length === 0
|
||||||
? bookmarks
|
? bookmarks
|
||||||
: bookmarks.filter(
|
: bookmarks.filter(
|
||||||
(bk) =>
|
(bk) =>
|
||||||
bk.previewText.toLowerCase().includes(query) ||
|
bk.previewText.toLowerCase().includes(query) ||
|
||||||
bk.roomName.toLowerCase().includes(query),
|
bk.roomName.toLowerCase().includes(query),
|
||||||
|
),
|
||||||
|
[bookmarks, query],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prune collapsed roomIds that no longer have any bookmark, so a room re-saved
|
||||||
|
// later doesn't reappear pre-collapsed and the Set can't grow unbounded.
|
||||||
|
useEffect(() => {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
if (prev.size === 0) return prev;
|
||||||
|
const live = new Set(bookmarks.map((bk) => bk.roomId));
|
||||||
|
let changed = false;
|
||||||
|
const next = new Set<string>();
|
||||||
|
prev.forEach((roomId) => {
|
||||||
|
if (live.has(roomId)) next.add(roomId);
|
||||||
|
else changed = true;
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [bookmarks]);
|
||||||
|
|
||||||
|
// Live render when the room is joined (useRoomEvent needs a non-null Room);
|
||||||
|
// otherwise fall back to the stored snapshot for rooms we've left. Shared by
|
||||||
|
// both the flat (newest/oldest) and grouped (by room) render paths.
|
||||||
|
const renderItem = useCallback(
|
||||||
|
(bk: Bookmark) => {
|
||||||
|
const room = mx.getRoom(bk.roomId);
|
||||||
|
return room ? (
|
||||||
|
<LiveBookmarkItem
|
||||||
|
key={bk.eventId}
|
||||||
|
room={room}
|
||||||
|
bookmark={bk}
|
||||||
|
onJump={handleJump}
|
||||||
|
onRemove={removeBookmark}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BookmarkItem
|
||||||
|
key={bk.eventId}
|
||||||
|
bookmark={bk}
|
||||||
|
onJump={handleJump}
|
||||||
|
onRemove={removeBookmark}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[mx, handleJump, removeBookmark],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedItems = useMemo(
|
||||||
|
() => (sort === 'room' ? filtered : sortBookmarks(filtered, sort)),
|
||||||
|
[filtered, sort],
|
||||||
|
);
|
||||||
|
const groups = useMemo(
|
||||||
|
() => (sort === 'room' ? groupBookmarksByRoom(filtered) : []),
|
||||||
|
[filtered, sort],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -237,11 +422,23 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{bookmarks.length > 0 && (
|
{bookmarks.length > 0 && (
|
||||||
<Text size="T200" priority="300">
|
<>
|
||||||
|
<Text size="T200" priority="300" truncate>
|
||||||
{filtered.length === bookmarks.length
|
{filtered.length === bookmarks.length
|
||||||
? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}`
|
? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}`
|
||||||
: `${filtered.length} of ${bookmarks.length} messages`}
|
: `${filtered.length} of ${bookmarks.length} messages`}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Box as="div" role="group" aria-label="Sort saved messages" gap="100">
|
||||||
|
{SORT_OPTIONS.map((opt) => (
|
||||||
|
<SortButton
|
||||||
|
key={opt.value}
|
||||||
|
label={opt.label}
|
||||||
|
active={sort === opt.value}
|
||||||
|
onClick={() => setSort(opt.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -265,27 +462,29 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box className={css.BookmarksContent} direction="Column" gap="200">
|
<Box className={css.BookmarksContent} direction="Column" gap="200">
|
||||||
{filtered.map((bk) => {
|
{sort === 'room'
|
||||||
// Live render when the room is joined (useRoomEvent needs a non-null Room);
|
? groups.map((group) => {
|
||||||
// otherwise fall back to the stored snapshot for rooms we've left.
|
const isCollapsed = collapsed.has(group.roomId);
|
||||||
const room = mx.getRoom(bk.roomId);
|
const contentId = `bookmark-group-${group.roomId}`;
|
||||||
return room ? (
|
return (
|
||||||
<LiveBookmarkItem
|
<Box key={group.roomId} direction="Column" gap="200">
|
||||||
key={bk.eventId}
|
<RoomGroupHeader
|
||||||
room={room}
|
roomId={group.roomId}
|
||||||
bookmark={bk}
|
roomName={group.roomName}
|
||||||
onJump={handleJump}
|
count={group.items.length}
|
||||||
onRemove={removeBookmark}
|
collapsed={isCollapsed}
|
||||||
/>
|
contentId={contentId}
|
||||||
) : (
|
onToggle={() => toggleGroup(group.roomId)}
|
||||||
<BookmarkItem
|
|
||||||
key={bk.eventId}
|
|
||||||
bookmark={bk}
|
|
||||||
onJump={handleJump}
|
|
||||||
onRemove={removeBookmark}
|
|
||||||
/>
|
/>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<Box id={contentId} direction="Column" gap="200">
|
||||||
|
{group.items.map((bk) => renderItem(bk))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
: sortedItems.map((bk) => renderItem(bk))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ interface ScheduleMessageModalProps {
|
|||||||
initialSendAt?: number;
|
initialSendAt?: number;
|
||||||
/** Header title; defaults to "Schedule Message". */
|
/** Header title; defaults to "Schedule Message". */
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** Primary-button label; defaults to "Schedule" (e.g. "Reschedule" when editing). */
|
||||||
|
submitLabel?: string;
|
||||||
onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
|
onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
@@ -94,6 +96,7 @@ export function ScheduleMessageModal({
|
|||||||
initialBody,
|
initialBody,
|
||||||
initialSendAt,
|
initialSendAt,
|
||||||
title = 'Schedule Message',
|
title = 'Schedule Message',
|
||||||
|
submitLabel = 'Schedule',
|
||||||
onScheduled,
|
onScheduled,
|
||||||
onClose,
|
onClose,
|
||||||
}: ScheduleMessageModalProps) {
|
}: ScheduleMessageModalProps) {
|
||||||
@@ -179,7 +182,7 @@ export function ScheduleMessageModal({
|
|||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: '#schedule-message-body',
|
||||||
onDeactivate: onClose,
|
onDeactivate: onClose,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
@@ -341,7 +344,7 @@ export function ScheduleMessageModal({
|
|||||||
disabled={submitting || !preview}
|
disabled={submitting || !preview}
|
||||||
before={submitting ? <Spinner variant="Primary" size="100" /> : undefined}
|
before={submitting ? <Spinner variant="Primary" size="100" /> : undefined}
|
||||||
>
|
>
|
||||||
<Text size="B400">Schedule</Text>
|
<Text size="B400">{submitLabel}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -115,12 +115,25 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
// cancel leaves it visible (and retriable) instead of letting it silently fire.
|
// cancel leaves it visible (and retriable) instead of letting it silently fire.
|
||||||
const handleEdit = useCallback(
|
const handleEdit = useCallback(
|
||||||
(oldMsg: ScheduledMessage, newDelayId: string, sendAt: number, content: IContent) => {
|
(oldMsg: ScheduledMessage, newDelayId: string, sendAt: number, content: IContent) => {
|
||||||
|
// Add the newly-scheduled message up front (nothing lost yet).
|
||||||
setScheduledMessages((prev) => {
|
setScheduledMessages((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
const current = (next.get(roomId) ?? []).filter((m) => m.delayId !== newDelayId);
|
const current = (next.get(roomId) ?? []).filter((m) => m.delayId !== newDelayId);
|
||||||
next.set(roomId, [{ delayId: newDelayId, roomId, content, sendAt }, ...current]);
|
next.set(roomId, [{ delayId: newDelayId, roomId, content, sendAt }, ...current]);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
// Mark the old message as cancelling so its row's Edit/Cancel buttons are
|
||||||
|
// disabled while we tear it down. Without this the old row stays live during
|
||||||
|
// the in-flight cancel and a second edit could orphan a still-scheduled event
|
||||||
|
// (both would fire). Also clear any stale error from a prior failed cancel.
|
||||||
|
setCancelling((prev) => new Set(prev).add(oldMsg.delayId));
|
||||||
|
setCancelErrors((prev) => {
|
||||||
|
if (!prev.has(oldMsg.delayId)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(oldMsg.delayId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setEditing(null);
|
||||||
cancelScheduledMessage(mx, oldMsg.delayId)
|
cancelScheduledMessage(mx, oldMsg.delayId)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setScheduledMessages((prev) => {
|
setScheduledMessages((prev) => {
|
||||||
@@ -131,8 +144,28 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => setCancelErrors((prev) => new Set(prev).add(oldMsg.delayId)));
|
.catch(() => {
|
||||||
setEditing(null);
|
// Cancel failed — the old delayed event is still live server-side, so it
|
||||||
|
// must stay visible and retriable. Re-insert it if auto-prune removed the
|
||||||
|
// row while the modal was open, otherwise the failure (and the duplicate
|
||||||
|
// it will send) would be invisible.
|
||||||
|
setScheduledMessages((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const current = next.get(roomId) ?? [];
|
||||||
|
if (!current.some((m) => m.delayId === oldMsg.delayId)) {
|
||||||
|
next.set(roomId, [...current, oldMsg]);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setCancelErrors((prev) => new Set(prev).add(oldMsg.delayId));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setCancelling((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(oldMsg.delayId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[mx, roomId, setScheduledMessages],
|
[mx, roomId, setScheduledMessages],
|
||||||
);
|
);
|
||||||
@@ -147,6 +180,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
initialBody={typeof editing.content.body === 'string' ? editing.content.body : ''}
|
initialBody={typeof editing.content.body === 'string' ? editing.content.body : ''}
|
||||||
initialSendAt={editing.sendAt}
|
initialSendAt={editing.sendAt}
|
||||||
title="Edit scheduled message"
|
title="Edit scheduled message"
|
||||||
|
submitLabel="Reschedule"
|
||||||
onScheduled={(newDelayId, sendAt, content) =>
|
onScheduled={(newDelayId, sendAt, content) =>
|
||||||
handleEdit(editing, newDelayId, sendAt, content)
|
handleEdit(editing, newDelayId, sendAt, content)
|
||||||
}
|
}
|
||||||
@@ -183,7 +217,11 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
{/* Tray items */}
|
{/* Tray items */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => {
|
||||||
|
const bodyPreview =
|
||||||
|
typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)';
|
||||||
|
const rowDesc = `${bodyPreview} at ${formatSendAt(msg.sendAt)}`;
|
||||||
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={msg.delayId}
|
key={msg.delayId}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
@@ -203,9 +241,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{typeof msg.content.body === 'string'
|
{bodyPreview}
|
||||||
? (msg.content.body as string)
|
|
||||||
: '(message)'}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
{formatSendAt(msg.sendAt)}
|
{formatSendAt(msg.sendAt)}
|
||||||
@@ -214,7 +250,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
aria-label="Edit scheduled message"
|
aria-label={`Edit scheduled message: ${rowDesc}`}
|
||||||
disabled={cancelling.has(msg.delayId)}
|
disabled={cancelling.has(msg.delayId)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -227,7 +263,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
aria-label="Cancel scheduled message"
|
aria-label={`Cancel scheduled message: ${rowDesc}`}
|
||||||
disabled={cancelling.has(msg.delayId)}
|
disabled={cancelling.has(msg.delayId)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -246,7 +282,8 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { sortBookmarks, groupBookmarksByRoom, isBookmarkSort } from './bookmarks';
|
||||||
|
import { Bookmark } from '../hooks/useBookmarks';
|
||||||
|
|
||||||
|
const bk = (eventId: string, roomId: string, savedAt: number, roomName = roomId): Bookmark => ({
|
||||||
|
eventId,
|
||||||
|
roomId,
|
||||||
|
savedAt,
|
||||||
|
roomName,
|
||||||
|
previewText: eventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sortBookmarks newest orders by savedAt descending', () => {
|
||||||
|
const input = [bk('a', 'r1', 100), bk('b', 'r1', 300), bk('c', 'r1', 200)];
|
||||||
|
const out = sortBookmarks(input, 'newest');
|
||||||
|
assert.deepEqual(
|
||||||
|
out.map((b) => b.eventId),
|
||||||
|
['b', 'c', 'a'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sortBookmarks oldest orders by savedAt ascending', () => {
|
||||||
|
const input = [bk('a', 'r1', 100), bk('b', 'r1', 300), bk('c', 'r1', 200)];
|
||||||
|
const out = sortBookmarks(input, 'oldest');
|
||||||
|
assert.deepEqual(
|
||||||
|
out.map((b) => b.eventId),
|
||||||
|
['a', 'c', 'b'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sortBookmarks room falls back to newest for a flat list', () => {
|
||||||
|
const input = [bk('a', 'r1', 100), bk('b', 'r2', 300)];
|
||||||
|
const out = sortBookmarks(input, 'room');
|
||||||
|
assert.deepEqual(
|
||||||
|
out.map((b) => b.eventId),
|
||||||
|
['b', 'a'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sortBookmarks breaks savedAt ties deterministically by eventId', () => {
|
||||||
|
const input = [bk('z', 'r1', 100), bk('a', 'r1', 100), bk('m', 'r1', 100)];
|
||||||
|
const newest = sortBookmarks(input, 'newest');
|
||||||
|
const oldest = sortBookmarks(input, 'oldest');
|
||||||
|
// Equal timestamps → ascending eventId in both directions.
|
||||||
|
assert.deepEqual(
|
||||||
|
newest.map((b) => b.eventId),
|
||||||
|
['a', 'm', 'z'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
oldest.map((b) => b.eventId),
|
||||||
|
['a', 'm', 'z'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sortBookmarks does not mutate its input', () => {
|
||||||
|
const input = [bk('a', 'r1', 100), bk('b', 'r1', 300)];
|
||||||
|
const before = input.map((b) => b.eventId);
|
||||||
|
sortBookmarks(input, 'oldest');
|
||||||
|
assert.deepEqual(
|
||||||
|
input.map((b) => b.eventId),
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('groupBookmarksByRoom buckets by room, newest-first within a group', () => {
|
||||||
|
const input = [
|
||||||
|
bk('a', 'r1', 100),
|
||||||
|
bk('b', 'r2', 500),
|
||||||
|
bk('c', 'r1', 300),
|
||||||
|
bk('d', 'r2', 200),
|
||||||
|
];
|
||||||
|
const groups = groupBookmarksByRoom(input);
|
||||||
|
assert.equal(groups.length, 2);
|
||||||
|
const r1 = groups.find((g) => g.roomId === 'r1')!;
|
||||||
|
const r2 = groups.find((g) => g.roomId === 'r2')!;
|
||||||
|
assert.deepEqual(
|
||||||
|
r1.items.map((b) => b.eventId),
|
||||||
|
['c', 'a'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
r2.items.map((b) => b.eventId),
|
||||||
|
['b', 'd'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('groupBookmarksByRoom orders groups by most-recent save (active rooms first)', () => {
|
||||||
|
const input = [bk('a', 'r1', 100), bk('c', 'r1', 300), bk('b', 'r2', 500)];
|
||||||
|
// r2's newest is 500, r1's newest is 300 → r2 first.
|
||||||
|
const groups = groupBookmarksByRoom(input);
|
||||||
|
assert.deepEqual(
|
||||||
|
groups.map((g) => g.roomId),
|
||||||
|
['r2', 'r1'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('groupBookmarksByRoom carries the stored room name', () => {
|
||||||
|
const groups = groupBookmarksByRoom([bk('a', 'r1', 100, 'General')]);
|
||||||
|
assert.equal(groups[0].roomName, 'General');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('groupBookmarksByRoom orders equal-newest groups deterministically by roomId', () => {
|
||||||
|
// Both rooms' newest save is 200 → tie broken by roomId ascending.
|
||||||
|
const input = [bk('a', 'rB', 200), bk('b', 'rA', 200), bk('c', 'rA', 100)];
|
||||||
|
const groups = groupBookmarksByRoom(input);
|
||||||
|
assert.deepEqual(
|
||||||
|
groups.map((g) => g.roomId),
|
||||||
|
['rA', 'rB'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('groupBookmarksByRoom does not mutate its input', () => {
|
||||||
|
const input = [bk('a', 'r1', 100), bk('b', 'r2', 300), bk('c', 'r1', 200)];
|
||||||
|
const before = input.map((b) => b.eventId);
|
||||||
|
groupBookmarksByRoom(input);
|
||||||
|
assert.deepEqual(
|
||||||
|
input.map((b) => b.eventId),
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('groupBookmarksByRoom returns empty for empty input', () => {
|
||||||
|
assert.deepEqual(groupBookmarksByRoom([]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isBookmarkSort accepts valid values and rejects everything else', () => {
|
||||||
|
assert.equal(isBookmarkSort('newest'), true);
|
||||||
|
assert.equal(isBookmarkSort('oldest'), true);
|
||||||
|
assert.equal(isBookmarkSort('room'), true);
|
||||||
|
assert.equal(isBookmarkSort('bogus'), false);
|
||||||
|
assert.equal(isBookmarkSort(''), false);
|
||||||
|
assert.equal(isBookmarkSort(undefined), false);
|
||||||
|
assert.equal(isBookmarkSort(null), false);
|
||||||
|
assert.equal(isBookmarkSort(3), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Bookmark } from '../hooks/useBookmarks';
|
||||||
|
|
||||||
|
export type BookmarkSort = 'newest' | 'oldest' | 'room';
|
||||||
|
|
||||||
|
const BOOKMARK_SORTS: readonly BookmarkSort[] = ['newest', 'oldest', 'room'];
|
||||||
|
|
||||||
|
/** Type guard for persisted/untrusted sort values (localStorage can hold stale/corrupt data). */
|
||||||
|
export function isBookmarkSort(value: unknown): value is BookmarkSort {
|
||||||
|
return typeof value === 'string' && (BOOKMARK_SORTS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BookmarkRoomGroup = {
|
||||||
|
roomId: string;
|
||||||
|
roomName: string;
|
||||||
|
items: Bookmark[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deterministic newest-first comparator: most recently saved first, ties broken
|
||||||
|
// by eventId so the order is stable regardless of input order (keeps tests and
|
||||||
|
// re-renders from shuffling equal-timestamp entries).
|
||||||
|
const byNewest = (a: Bookmark, b: Bookmark): number =>
|
||||||
|
b.savedAt - a.savedAt || (a.eventId < b.eventId ? -1 : a.eventId > b.eventId ? 1 : 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a new array of bookmarks ordered per the selected sort. `room` has no
|
||||||
|
* flat ordering of its own (grouping is a render concern) and falls back to
|
||||||
|
* newest-first so callers can still render a sensible flat list if they want.
|
||||||
|
*/
|
||||||
|
export function sortBookmarks(bookmarks: Bookmark[], sort: BookmarkSort): Bookmark[] {
|
||||||
|
const copy = [...bookmarks];
|
||||||
|
if (sort === 'oldest') {
|
||||||
|
// Oldest-first is the reverse ordering; keep the same eventId tie-break shape.
|
||||||
|
return copy.sort(
|
||||||
|
(a, b) => a.savedAt - b.savedAt || (a.eventId < b.eventId ? -1 : a.eventId > b.eventId ? 1 : 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return copy.sort(byNewest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bucket bookmarks by room. Items within a group are newest-first; groups are
|
||||||
|
* ordered by each group's most-recently-saved bookmark (active rooms float up),
|
||||||
|
* with the room's stored name (the panel overrides with the live name when the
|
||||||
|
* room is joined). Deterministic tie-breaks keep the output stable.
|
||||||
|
*/
|
||||||
|
export function groupBookmarksByRoom(bookmarks: Bookmark[]): BookmarkRoomGroup[] {
|
||||||
|
const groups = new Map<string, BookmarkRoomGroup>();
|
||||||
|
bookmarks.forEach((bk) => {
|
||||||
|
const existing = groups.get(bk.roomId);
|
||||||
|
if (existing) {
|
||||||
|
existing.items.push(bk);
|
||||||
|
} else {
|
||||||
|
groups.set(bk.roomId, { roomId: bk.roomId, roomName: bk.roomName, items: [bk] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = [...groups.values()];
|
||||||
|
result.forEach((group) => group.items.sort(byNewest));
|
||||||
|
// Order groups by their newest item's savedAt (desc); tie-break by roomId.
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const diff = b.items[0].savedAt - a.items[0].savedAt;
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
return a.roomId < b.roomId ? -1 : a.roomId > b.roomId ? 1 : 0;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user