feat: document title unread count, draft persistence, search date range
CI / Build & Quality Checks (push) Successful in 10m30s

E1 - Document title unread count: FaviconUpdater now also sets
     document.title to '(N) Lotus Chat' for mentions, '· Lotus Chat'
     for plain unreads, and 'Lotus Chat' when clear. Reuses the
     existing roomToUnread forEach loop.

E2 - Draft persistence across reloads: on room unmount, unsent message
     is written to localStorage as 'draft-msg-<roomId>'. On mount, if
     the Jotai atom is empty (page reload), the localStorage draft is
     restored. Cleared on send. Uses the existing Slate node JSON format.

E5 - Search date range filter: new DateRangeButton in SearchFilters
     with From/To date inputs in a PopOut. Dates stored as epoch ms in
     ?fromTs=&toTs= URL params. Passed to Matrix /search as from_ts /
     to_ts filter fields (valid spec fields, cast via 'as any' since
     SDK types don't include them yet).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 22:22:40 -04:00
parent 7b14eb539f
commit dfedba9ef8
6 changed files with 188 additions and 13 deletions
@@ -33,6 +33,8 @@ const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSe
order: searchParams.get('order') ?? undefined,
rooms: searchParams.get('rooms') ?? undefined,
senders: searchParams.get('senders') ?? undefined,
fromTs: searchParams.get('fromTs') ?? undefined,
toTs: searchParams.get('toTs') ?? undefined,
}),
[searchParams],
);
@@ -193,6 +195,8 @@ export function MessageSearch({
order: searchPathSearchParams.order ?? SearchOrderBy.Recent,
rooms: searchParamRooms ?? defaultRooms,
senders: searchParamsSenders ?? senders,
fromTs: searchPathSearchParams.fromTs ? Number(searchPathSearchParams.fromTs) : undefined,
toTs: searchPathSearchParams.toTs ? Number(searchPathSearchParams.toTs) : undefined,
};
}, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
@@ -235,6 +239,8 @@ export function MessageSearch({
msgSearchParams.order,
msgSearchParams.rooms,
msgSearchParams.senders,
msgSearchParams.fromTs,
msgSearchParams.toTs,
],
queryFn: ({ pageParam }) => searchMessages(pageParam),
initialPageParam: '',
@@ -329,6 +335,20 @@ export function MessageSearch({
[searchParamsSenders, handleSelectedSendersChange],
);
const handleDateRangeChange = useCallback(
(fromTs?: number, toTs?: number) => {
setSearchParams((prevParams) => {
const p = new URLSearchParams(prevParams);
p.delete('fromTs');
p.delete('toTs');
if (fromTs) p.append('fromTs', String(fromTs));
if (toTs) p.append('toTs', String(toTs));
return p;
});
},
[setSearchParams],
);
const lastVItem = vItems[vItems.length - 1];
const lastVItemIndex: number | undefined = lastVItem?.index;
const lastGroupIndex = groups.length - 1;
@@ -378,6 +398,9 @@ export function MessageSearch({
onOrderChange={handleOrderChange}
selectedSenders={searchParamsSenders}
onSelectedSendersChange={handleSelectedSendersChange}
fromTs={msgSearchParams.fromTs}
toTs={msgSearchParams.toTs}
onDateRangeChange={handleDateRangeChange}
/>
</Box>
@@ -326,6 +326,123 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
);
}
type DateRangeButtonProps = {
fromTs?: number;
toTs?: number;
onChange: (fromTs?: number, toTs?: number) => void;
};
function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const toISODate = (ts: number) => new Date(ts).toISOString().split('T')[0];
const fromDate = fromTs ? toISODate(fromTs) : '';
const toDate = toTs ? toISODate(toTs) : '';
const handleFrom = (val: string) => {
onChange(val ? new Date(val).setHours(0, 0, 0, 0) : undefined, toTs);
};
const handleTo = (val: string) => {
onChange(fromTs, val ? new Date(val).setHours(23, 59, 59, 999) : undefined);
};
const hasRange = !!fromTs || !!toTs;
const label = hasRange
? [fromTs && toISODate(fromTs), toTs && toISODate(toTs)].filter(Boolean).join(' ')
: 'Date range';
return (
<PopOut
anchor={menuAnchor}
align="End"
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
}}
>
<Menu variant="Surface" style={{ padding: config.space.S300, minWidth: toRem(220) }}>
<Box direction="Column" gap="200">
<Box direction="Column" gap="100">
<Text size="L400">From</Text>
<input
type="date"
value={fromDate}
max={toDate || undefined}
onChange={(e) => handleFrom(e.target.value)}
style={{
background: 'var(--bg-surface-variant)',
border: '1px solid var(--border-surface-variant)',
borderRadius: config.radii.R300,
color: 'inherit',
fontSize: '0.82rem',
padding: `${config.space.S100} ${config.space.S200}`,
}}
/>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">To</Text>
<input
type="date"
value={toDate}
min={fromDate || undefined}
onChange={(e) => handleTo(e.target.value)}
style={{
background: 'var(--bg-surface-variant)',
border: '1px solid var(--border-surface-variant)',
borderRadius: config.radii.R300,
color: 'inherit',
fontSize: '0.82rem',
padding: `${config.space.S100} ${config.space.S200}`,
}}
/>
</Box>
{hasRange && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
onClick={() => {
onChange(undefined, undefined);
setMenuAnchor(undefined);
}}
>
<Text size="B300">Clear</Text>
</Button>
)}
</Box>
</Menu>
</FocusTrap>
}
>
<Chip
variant={hasRange ? 'Primary' : 'SurfaceVariant'}
radii="Pill"
before={<Icon size="100" src={Icons.RecentClock} />}
after={
hasRange ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onChange(undefined, undefined);
}}
/>
) : undefined
}
onClick={(e: React.MouseEvent<HTMLButtonElement>) =>
setMenuAnchor(e.currentTarget.getBoundingClientRect())
}
>
<Text size="T200">{label}</Text>
</Chip>
</PopOut>
);
}
type SearchFiltersProps = {
defaultRoomsFilterName: string;
allowGlobal?: boolean;
@@ -338,6 +455,9 @@ type SearchFiltersProps = {
onOrderChange: (order?: string) => void;
selectedSenders?: string[];
onSelectedSendersChange: (senders?: string[]) => void;
fromTs?: number;
toTs?: number;
onDateRangeChange: (fromTs?: number, toTs?: number) => void;
};
export function SearchFilters({
defaultRoomsFilterName,
@@ -351,6 +471,9 @@ export function SearchFilters({
onOrderChange,
selectedSenders,
onSelectedSendersChange,
fromTs,
toTs,
onDateRangeChange,
}: SearchFiltersProps) {
const mx = useMatrixClient();
@@ -433,6 +556,7 @@ export function SearchFilters({
);
})}
<Box grow="Yes" data-spacing-node />
<DateRangeButton fromTs={fromTs} toTs={toTs} onChange={onDateRangeChange} />
<OrderButton order={order} onChange={onOrderChange} />
</Box>
</Box>
@@ -68,10 +68,12 @@ export type MessageSearchParams = {
order?: string;
rooms?: string[];
senders?: string[];
fromTs?: number;
toTs?: number;
};
export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient();
const { term, order, rooms, senders } = params;
const { term, order, rooms, senders, fromTs, toTs } = params;
const searchMessages = useCallback(
async (nextBatch?: string) => {
@@ -90,11 +92,15 @@ export const useMessageSearch = (params: MessageSearchParams) => {
after_limit: 0,
include_profile: false,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
filter: {
limit,
rooms,
senders,
},
// from_ts / to_ts are valid Matrix spec fields not yet in SDK types
...(fromTs !== undefined && { from_ts: fromTs }),
...(toTs !== undefined && { to_ts: toTs }),
} as any,
include_state: false,
order_by: order as SearchOrderBy.Recent,
search_term: term,
+17 -1
View File
@@ -314,16 +314,31 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
didRestoreDraft.current = true;
if (msgDraft.length > 0) {
Transforms.insertFragment(editor, msgDraft);
} else {
// Jotai draft is empty (page reload) — try localStorage fallback
try {
const stored = localStorage.getItem(`draft-msg-${roomId}`);
if (stored) {
const nodes = JSON.parse(stored);
if (Array.isArray(nodes) && nodes.length > 0) {
Transforms.insertFragment(editor, nodes);
}
}
} catch {
// Ignore malformed stored draft
}
}
}, [editor, msgDraft]);
}, [editor, msgDraft, roomId]);
useEffect(
() => () => {
if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
localStorage.setItem(`draft-msg-${roomId}`, JSON.stringify(parsedDraft));
} else {
setMsgDraft([]);
localStorage.removeItem(`draft-msg-${roomId}`);
}
resetEditor(editor);
resetEditorHistory(editor);
@@ -463,6 +478,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
mx.sendMessage(roomId, content as any);
resetEditor(editor);
resetEditorHistory(editor);
localStorage.removeItem(`draft-msg-${roomId}`);
setReplyDraft(undefined);
sendTypingStatus(false);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
+14 -10
View File
@@ -56,22 +56,26 @@ function FaviconUpdater() {
const roomToUnread = useAtomValue(roomToUnreadAtom);
useEffect(() => {
let notification = false;
let highlight = false;
let totalNotif = 0;
let totalHighlight = 0;
roomToUnread.forEach((unread) => {
if (unread.total > 0) {
notification = true;
}
if (unread.highlight > 0) {
highlight = true;
}
totalNotif += unread.total;
totalHighlight += unread.highlight;
});
if (notification) {
setFavicon(highlight ? LogoHighlightSVG : LogoUnreadSVG);
if (totalNotif > 0) {
setFavicon(totalHighlight > 0 ? LogoHighlightSVG : LogoUnreadSVG);
} else {
setFavicon(LogoSVG);
}
if (totalHighlight > 0) {
document.title = `(${totalHighlight}) Lotus Chat`;
} else if (totalNotif > 0) {
document.title = `· Lotus Chat`;
} else {
document.title = 'Lotus Chat';
}
}, [roomToUnread]);
return null;
+2
View File
@@ -33,6 +33,8 @@ export type _SearchPathSearchParams = {
order?: string;
rooms?: string;
senders?: string;
fromTs?: string;
toTs?: string;
};
export const _SEARCH_PATH = 'search/';