Search: all results with a real count; bulk actions on results stay done
Search showed at most the first 50 matching messages ("1–50 of 50"). The
first page now carries the number of matching conversations, further pages
load as the list is paged through (a conversation spanning two appears once;
oldest-first loads them all, up to 40 pages), and the Unread/Pinned tabs filter
on the server, so their count covers every result.
Bulk Delete / Archive / Restore over selected results acts at once: the rows
leave the list (greyed instead when archived from a search, which still finds
them), the search results are refreshed, and the reading pane returns to
"Select a conversation". Each move landing on the server refreshes them again,
and a hidden row only comes back when no move has landed for 30 seconds — a
long batch no longer brings deleted results back.
Settings → Accounts → Notifications: shorter description.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import signetLogo from "../../icons/ultimo-logo-signet.svg";
|
||||
@@ -42,6 +48,7 @@ import type { AccountOut, QuotaOut, SavedSearch, ThreadSummary } from "../lib/ty
|
||||
import { useReadingPane } from "../lib/readingPane";
|
||||
import { useIsMobile } from "../lib/useIsMobile";
|
||||
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
||||
import { subscribeWs } from "../lib/ws";
|
||||
import { PluginSlot } from "../plugins/PluginSlot";
|
||||
|
||||
function quotaLabel(q: QuotaOut): string {
|
||||
@@ -57,6 +64,9 @@ const PAGE_SIZE = 50;
|
||||
// How long a row the list hid for a queued move waits for the move to land
|
||||
// before it is shown again.
|
||||
const HIDE_FALLBACK_MS = 30_000;
|
||||
// Search results load 50 matching messages at a time; this caps how many
|
||||
// pages an oldest-first search (which needs them all) fetches.
|
||||
const MAX_SEARCH_PAGES = 40;
|
||||
|
||||
// Collapsed sidebar width: just wide enough for the icons.
|
||||
const COLLAPSED_W = 60;
|
||||
@@ -271,11 +281,28 @@ export function MailLayout() {
|
||||
const effectiveQuery = searchScope
|
||||
? `${searchScope} ${debouncedTerm}`.trim()
|
||||
: debouncedTerm;
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ["search", effectiveQuery],
|
||||
queryFn: () => api.search(effectiveQuery),
|
||||
// Search comes in pages of matching messages (a conversation can span two);
|
||||
// more are fetched as the list is paged through (effect below).
|
||||
const searchFilter = filter === "unread" || filter === "pinned" ? filter : undefined;
|
||||
const searchQuery = useInfiniteQuery({
|
||||
queryKey: ["search", effectiveQuery, searchFilter],
|
||||
queryFn: ({ pageParam }) => api.search(effectiveQuery, pageParam, searchFilter),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.cursor,
|
||||
enabled: searching,
|
||||
});
|
||||
const searchThreads = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: ThreadSummary[] = [];
|
||||
for (const pg of searchQuery.data?.pages ?? []) {
|
||||
for (const h of pg.threads) {
|
||||
if (seen.has(h.thread.id)) continue;
|
||||
seen.add(h.thread.id);
|
||||
out.push(h.thread);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [searchQuery.data]);
|
||||
const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches });
|
||||
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
|
||||
@@ -296,12 +323,15 @@ export function MailLayout() {
|
||||
let pageThreads: ThreadSummary[];
|
||||
let total: number;
|
||||
if (searching) {
|
||||
const hits = (searchQuery.data?.threads ?? []).map((h) => h.thread);
|
||||
const f = hits.filter((th) =>
|
||||
filter === "unread" ? th.unread_count > 0 : filter === "pinned" ? th.pinned : true,
|
||||
);
|
||||
const ordered = listOrder === "newest" ? f : [...f].reverse();
|
||||
total = ordered.length;
|
||||
const shown = searchThreads.filter((th) => !hiddenIds.has(th.id));
|
||||
const ordered = listOrder === "newest" ? shown : [...shown].reverse();
|
||||
// The server's count of matching conversations; without one (or while
|
||||
// still loading pages), what has been loaded.
|
||||
const counted = searchQuery.data?.pages[0]?.total;
|
||||
total =
|
||||
counted != null
|
||||
? Math.max(ordered.length, counted - (searchThreads.length - shown.length))
|
||||
: ordered.length;
|
||||
pageThreads = ordered.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE);
|
||||
} else {
|
||||
const all = threadsQuery.data?.threads ?? [];
|
||||
@@ -310,6 +340,21 @@ export function MailLayout() {
|
||||
}
|
||||
const pageStart = page * PAGE_SIZE;
|
||||
const pageEnd = pageStart + pageThreads.length;
|
||||
// Search: fetch further pages of results as far as the page shown needs (one
|
||||
// conversation more, to know whether a next page exists) — or all of them
|
||||
// for oldest-first, whose first page is the end of the results.
|
||||
const {
|
||||
hasNextPage: searchHasMore,
|
||||
isFetchingNextPage: searchFetchingMore,
|
||||
fetchNextPage: fetchMoreSearch,
|
||||
} = searchQuery;
|
||||
const searchLoaded = searchThreads.length;
|
||||
const searchPages = searchQuery.data?.pages.length ?? 0;
|
||||
useEffect(() => {
|
||||
if (!searching || !searchHasMore || searchFetchingMore) return;
|
||||
const wanted = listOrder === "newest" ? (page + 1) * PAGE_SIZE : Infinity;
|
||||
if (searchLoaded <= wanted && searchPages < MAX_SEARCH_PAGES) void fetchMoreSearch();
|
||||
}, [searching, searchHasMore, searchFetchingMore, fetchMoreSearch, searchLoaded, searchPages, page, listOrder]);
|
||||
// Reset to the first page whenever the list identity (not the page) changes.
|
||||
useEffect(() => setPage(0), [view, filter, debouncedTerm, listOrder]);
|
||||
|
||||
@@ -550,6 +595,28 @@ export function MailLayout() {
|
||||
// row is hidden straight away (it is leaving the folder's scope); in search it
|
||||
// may still match a global query, so it is greyed out instead. A failed
|
||||
// request puts the row back.
|
||||
// Moves are queued on the server and land one by one (each announced as
|
||||
// thread.updated). A hidden row comes back only when nothing has landed for
|
||||
// a while — the move failed — not while a long batch is still going.
|
||||
const lastMoveLanded = useRef(0);
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeWs((name) => {
|
||||
if (name === "thread.updated") lastMoveLanded.current = Date.now();
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const unhideIfStuck = (unmark: () => void) => {
|
||||
const check = () => {
|
||||
if (Date.now() - lastMoveLanded.current < HIDE_FALLBACK_MS) {
|
||||
window.setTimeout(check, HIDE_FALLBACK_MS);
|
||||
} else {
|
||||
unmark();
|
||||
}
|
||||
};
|
||||
window.setTimeout(check, HIDE_FALLBACK_MS);
|
||||
};
|
||||
|
||||
const listMove = async (id: string, action: (id: string) => Promise<unknown>) => {
|
||||
const mark = searching ? setDimmedIds : setHiddenIds;
|
||||
const unmark = () =>
|
||||
@@ -573,7 +640,7 @@ export function MailLayout() {
|
||||
// The server only queues the move, so the refetch above can still carry the
|
||||
// row; the effect below un-hides it once a list arrives without it. Should
|
||||
// the move never land, the row comes back rather than staying lost.
|
||||
if (!searching) window.setTimeout(unmark, HIDE_FALLBACK_MS);
|
||||
if (!searching) unhideIfStuck(unmark);
|
||||
};
|
||||
// A row stays hidden until a fetched list no longer contains it.
|
||||
useEffect(() => {
|
||||
@@ -586,6 +653,16 @@ export function MailLayout() {
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [threadsQuery.data]);
|
||||
// The same for search results (deleted conversations drop out of them).
|
||||
useEffect(() => {
|
||||
if (!searchQuery.data || !searching) return;
|
||||
setHiddenIds((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const present = new Set(searchThreads.map((th) => th.id));
|
||||
const next = new Set([...prev].filter((id) => present.has(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [searchQuery.data, searchThreads, searching]);
|
||||
const listArchive = (id: string) => listMove(id, api.archiveThread);
|
||||
const listTrash = (id: string) => listMove(id, api.trashThread);
|
||||
// Phone: a conversation swiped right goes to Trash — after a short Undo
|
||||
@@ -648,26 +725,52 @@ export function MailLayout() {
|
||||
|
||||
// Bulk actions run the given operation over every selected thread, then clear the
|
||||
// selection and refresh (navigating away if the open thread was among them).
|
||||
const runBulk = async (action: (id: string) => Promise<unknown>) => {
|
||||
//
|
||||
// Like the row buttons, the result shows at once: conversations leaving the
|
||||
// view (deleted; archived out of a folder) are hidden — in search too, which
|
||||
// leaves out Trash — and ones still matching a search (archived) are greyed.
|
||||
// The requests run behind; a failed one puts its row back.
|
||||
const runBulk = async (
|
||||
action: (id: string) => Promise<unknown>,
|
||||
effect: "hide" | "dim" | null = null,
|
||||
) => {
|
||||
const ids = [...checkedIds];
|
||||
const mark = effect === "hide" ? setHiddenIds : effect === "dim" ? setDimmedIds : null;
|
||||
const unmark = (gone: string[]) =>
|
||||
mark?.((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of gone) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
mark?.((prev) => new Set([...prev, ...ids]));
|
||||
setCheckedIds(new Set());
|
||||
// The conversations are gone from the list: the reading pane goes back to
|
||||
// "Select a conversation" rather than to whatever was open before selecting.
|
||||
if (threadId && (effect !== null || ids.includes(threadId))) navigate("/mail");
|
||||
const failed: string[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await action(id);
|
||||
} catch {
|
||||
/* keep going */
|
||||
failed.push(id);
|
||||
}
|
||||
}
|
||||
setCheckedIds(new Set());
|
||||
if (failed.length) unmark(failed);
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||
if (threadId && ids.includes(threadId)) navigate("/mail");
|
||||
// The server only queues the moves, so a refetch can still carry the rows;
|
||||
// they stay hidden until a list arrives without them — or come back should
|
||||
// the moves never land.
|
||||
if (effect === "hide") unhideIfStuck(() => unmark(ids));
|
||||
};
|
||||
const bulkArchive = () => runBulk((id) => api.archiveThread(id));
|
||||
const leaving = searching ? "dim" : "hide"; // archived: gone from a folder, still found by a search
|
||||
const bulkArchive = () => runBulk((id) => api.archiveThread(id), leaving);
|
||||
const bulkPin = () => runBulk((id) => api.threadFlags(id, ["\\Flagged"], []));
|
||||
const bulkTrash = () => runBulk((id) => api.trashThread(id));
|
||||
const bulkRestore = () => runBulk((id) => api.restoreThread(id));
|
||||
const bulkDeletePermanent = () => runBulk((id) => api.deleteThreadPermanent(id));
|
||||
const bulkTrash = () => runBulk((id) => api.trashThread(id), "hide");
|
||||
const bulkRestore = () => runBulk((id) => api.restoreThread(id), "hide");
|
||||
const bulkDeletePermanent = () => runBulk((id) => api.deleteThreadPermanent(id), "hide");
|
||||
|
||||
// Bulk-action buttons over the selected conversations: in the reading pane on
|
||||
// the desktop, above the list on a phone.
|
||||
|
||||
Reference in New Issue
Block a user