feat: document title unread count, draft persistence, search date range
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,
|
order: searchParams.get('order') ?? undefined,
|
||||||
rooms: searchParams.get('rooms') ?? undefined,
|
rooms: searchParams.get('rooms') ?? undefined,
|
||||||
senders: searchParams.get('senders') ?? undefined,
|
senders: searchParams.get('senders') ?? undefined,
|
||||||
|
fromTs: searchParams.get('fromTs') ?? undefined,
|
||||||
|
toTs: searchParams.get('toTs') ?? undefined,
|
||||||
}),
|
}),
|
||||||
[searchParams],
|
[searchParams],
|
||||||
);
|
);
|
||||||
@@ -193,6 +195,8 @@ export function MessageSearch({
|
|||||||
order: searchPathSearchParams.order ?? SearchOrderBy.Recent,
|
order: searchPathSearchParams.order ?? SearchOrderBy.Recent,
|
||||||
rooms: searchParamRooms ?? defaultRooms,
|
rooms: searchParamRooms ?? defaultRooms,
|
||||||
senders: searchParamsSenders ?? senders,
|
senders: searchParamsSenders ?? senders,
|
||||||
|
fromTs: searchPathSearchParams.fromTs ? Number(searchPathSearchParams.fromTs) : undefined,
|
||||||
|
toTs: searchPathSearchParams.toTs ? Number(searchPathSearchParams.toTs) : undefined,
|
||||||
};
|
};
|
||||||
}, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
|
}, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
|
||||||
|
|
||||||
@@ -235,6 +239,8 @@ export function MessageSearch({
|
|||||||
msgSearchParams.order,
|
msgSearchParams.order,
|
||||||
msgSearchParams.rooms,
|
msgSearchParams.rooms,
|
||||||
msgSearchParams.senders,
|
msgSearchParams.senders,
|
||||||
|
msgSearchParams.fromTs,
|
||||||
|
msgSearchParams.toTs,
|
||||||
],
|
],
|
||||||
queryFn: ({ pageParam }) => searchMessages(pageParam),
|
queryFn: ({ pageParam }) => searchMessages(pageParam),
|
||||||
initialPageParam: '',
|
initialPageParam: '',
|
||||||
@@ -329,6 +335,20 @@ export function MessageSearch({
|
|||||||
[searchParamsSenders, handleSelectedSendersChange],
|
[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 lastVItem = vItems[vItems.length - 1];
|
||||||
const lastVItemIndex: number | undefined = lastVItem?.index;
|
const lastVItemIndex: number | undefined = lastVItem?.index;
|
||||||
const lastGroupIndex = groups.length - 1;
|
const lastGroupIndex = groups.length - 1;
|
||||||
@@ -378,6 +398,9 @@ export function MessageSearch({
|
|||||||
onOrderChange={handleOrderChange}
|
onOrderChange={handleOrderChange}
|
||||||
selectedSenders={searchParamsSenders}
|
selectedSenders={searchParamsSenders}
|
||||||
onSelectedSendersChange={handleSelectedSendersChange}
|
onSelectedSendersChange={handleSelectedSendersChange}
|
||||||
|
fromTs={msgSearchParams.fromTs}
|
||||||
|
toTs={msgSearchParams.toTs}
|
||||||
|
onDateRangeChange={handleDateRangeChange}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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 = {
|
type SearchFiltersProps = {
|
||||||
defaultRoomsFilterName: string;
|
defaultRoomsFilterName: string;
|
||||||
allowGlobal?: boolean;
|
allowGlobal?: boolean;
|
||||||
@@ -338,6 +455,9 @@ type SearchFiltersProps = {
|
|||||||
onOrderChange: (order?: string) => void;
|
onOrderChange: (order?: string) => void;
|
||||||
selectedSenders?: string[];
|
selectedSenders?: string[];
|
||||||
onSelectedSendersChange: (senders?: string[]) => void;
|
onSelectedSendersChange: (senders?: string[]) => void;
|
||||||
|
fromTs?: number;
|
||||||
|
toTs?: number;
|
||||||
|
onDateRangeChange: (fromTs?: number, toTs?: number) => void;
|
||||||
};
|
};
|
||||||
export function SearchFilters({
|
export function SearchFilters({
|
||||||
defaultRoomsFilterName,
|
defaultRoomsFilterName,
|
||||||
@@ -351,6 +471,9 @@ export function SearchFilters({
|
|||||||
onOrderChange,
|
onOrderChange,
|
||||||
selectedSenders,
|
selectedSenders,
|
||||||
onSelectedSendersChange,
|
onSelectedSendersChange,
|
||||||
|
fromTs,
|
||||||
|
toTs,
|
||||||
|
onDateRangeChange,
|
||||||
}: SearchFiltersProps) {
|
}: SearchFiltersProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
@@ -433,6 +556,7 @@ export function SearchFilters({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Box grow="Yes" data-spacing-node />
|
<Box grow="Yes" data-spacing-node />
|
||||||
|
<DateRangeButton fromTs={fromTs} toTs={toTs} onChange={onDateRangeChange} />
|
||||||
<OrderButton order={order} onChange={onOrderChange} />
|
<OrderButton order={order} onChange={onOrderChange} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -68,10 +68,12 @@ export type MessageSearchParams = {
|
|||||||
order?: string;
|
order?: string;
|
||||||
rooms?: string[];
|
rooms?: string[];
|
||||||
senders?: string[];
|
senders?: string[];
|
||||||
|
fromTs?: number;
|
||||||
|
toTs?: number;
|
||||||
};
|
};
|
||||||
export const useMessageSearch = (params: MessageSearchParams) => {
|
export const useMessageSearch = (params: MessageSearchParams) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const { term, order, rooms, senders } = params;
|
const { term, order, rooms, senders, fromTs, toTs } = params;
|
||||||
|
|
||||||
const searchMessages = useCallback(
|
const searchMessages = useCallback(
|
||||||
async (nextBatch?: string) => {
|
async (nextBatch?: string) => {
|
||||||
@@ -90,11 +92,15 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
|||||||
after_limit: 0,
|
after_limit: 0,
|
||||||
include_profile: false,
|
include_profile: false,
|
||||||
},
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
filter: {
|
filter: {
|
||||||
limit,
|
limit,
|
||||||
rooms,
|
rooms,
|
||||||
senders,
|
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,
|
include_state: false,
|
||||||
order_by: order as SearchOrderBy.Recent,
|
order_by: order as SearchOrderBy.Recent,
|
||||||
search_term: term,
|
search_term: term,
|
||||||
|
|||||||
@@ -314,16 +314,31 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
didRestoreDraft.current = true;
|
didRestoreDraft.current = true;
|
||||||
if (msgDraft.length > 0) {
|
if (msgDraft.length > 0) {
|
||||||
Transforms.insertFragment(editor, msgDraft);
|
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(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (!isEmptyEditor(editor)) {
|
if (!isEmptyEditor(editor)) {
|
||||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||||
setMsgDraft(parsedDraft);
|
setMsgDraft(parsedDraft);
|
||||||
|
localStorage.setItem(`draft-msg-${roomId}`, JSON.stringify(parsedDraft));
|
||||||
} else {
|
} else {
|
||||||
setMsgDraft([]);
|
setMsgDraft([]);
|
||||||
|
localStorage.removeItem(`draft-msg-${roomId}`);
|
||||||
}
|
}
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
@@ -463,6 +478,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
mx.sendMessage(roomId, content as any);
|
mx.sendMessage(roomId, content as any);
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
|
localStorage.removeItem(`draft-msg-${roomId}`);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
||||||
|
|||||||
@@ -56,22 +56,26 @@ function FaviconUpdater() {
|
|||||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let notification = false;
|
let totalNotif = 0;
|
||||||
let highlight = false;
|
let totalHighlight = 0;
|
||||||
roomToUnread.forEach((unread) => {
|
roomToUnread.forEach((unread) => {
|
||||||
if (unread.total > 0) {
|
totalNotif += unread.total;
|
||||||
notification = true;
|
totalHighlight += unread.highlight;
|
||||||
}
|
|
||||||
if (unread.highlight > 0) {
|
|
||||||
highlight = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (notification) {
|
if (totalNotif > 0) {
|
||||||
setFavicon(highlight ? LogoHighlightSVG : LogoUnreadSVG);
|
setFavicon(totalHighlight > 0 ? LogoHighlightSVG : LogoUnreadSVG);
|
||||||
} else {
|
} else {
|
||||||
setFavicon(LogoSVG);
|
setFavicon(LogoSVG);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (totalHighlight > 0) {
|
||||||
|
document.title = `(${totalHighlight}) Lotus Chat`;
|
||||||
|
} else if (totalNotif > 0) {
|
||||||
|
document.title = `· Lotus Chat`;
|
||||||
|
} else {
|
||||||
|
document.title = 'Lotus Chat';
|
||||||
|
}
|
||||||
}, [roomToUnread]);
|
}, [roomToUnread]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export type _SearchPathSearchParams = {
|
|||||||
order?: string;
|
order?: string;
|
||||||
rooms?: string;
|
rooms?: string;
|
||||||
senders?: string;
|
senders?: string;
|
||||||
|
fromTs?: string;
|
||||||
|
toTs?: string;
|
||||||
};
|
};
|
||||||
export const _SEARCH_PATH = 'search/';
|
export const _SEARCH_PATH = 'search/';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user