feat: document title unread count, draft persistence, search date range
CI / Build & Quality Checks (push) Successful in 10m30s
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:
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -33,6 +33,8 @@ export type _SearchPathSearchParams = {
|
||||
order?: string;
|
||||
rooms?: string;
|
||||
senders?: string;
|
||||
fromTs?: string;
|
||||
toTs?: string;
|
||||
};
|
||||
export const _SEARCH_PATH = 'search/';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user