|
|
|
@@ -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.
|
|
|
|
|