mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 00:01:56 +00:00
Merge remote-tracking branch 'origin/main' into fix/unibox-refresh-load
This commit is contained in:
@@ -177,7 +177,7 @@ Archiving from a row files the whole conversation, not the message the row is sh
|
||||
|
||||
### Working on several at once
|
||||
|
||||
Each row has a tick box in its left gutter, in place of the unread dot. Hover a row to see it, or press **Select** in the list header, which shows every box at once and adds an **All** box that takes everything currently loaded. `x` ticks the row you are on from the keyboard, and holding `Shift` while ticking takes the whole run between that row and the last one you ticked.
|
||||
Each row has a tick box in its left gutter, in place of the unread dot. Hover a row to see it, or press **Select** in the list header, which shows every box at once and adds an **All** box that takes everything currently loaded. The **×** next to **All** leaves select mode and drops anything ticked, which is the way out on a phone; `Escape` does the same on a keyboard. `x` ticks the row you are on from the keyboard, and holding `Shift` while ticking takes the whole run between that row and the last one you ticked.
|
||||
|
||||
With anything ticked, a bar appears along the bottom of the screen with the count and the actions that apply to all of them: **Mark read**, **Mark unread**, **Snooze**, **Archive** (or **Move to inbox**) and **Delete**. Each one is a single request, so filing a screenful is as quick as filing one. **Clear** drops the selection, and so does `Escape`; changing view drops it too, since the rows it applied to are no longer the rows on screen.
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Mark as unread has to stick: the open reader marks what it shows as read,
|
||||
// so no path that marks the open conversation unread may be followed by a read.
|
||||
// In a browser the URL follows the store inside a router transition, so the
|
||||
// reader outlives the press; navigation is deferred here to reproduce that.
|
||||
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
|
||||
import { screen, act, fireEvent, waitFor } from "@testing-library/react";
|
||||
import type * as ReactRouterDom from "react-router-dom";
|
||||
import {
|
||||
installLayoutShims,
|
||||
mount,
|
||||
resetScrollTops,
|
||||
ROWS,
|
||||
setViewportWidth,
|
||||
settle,
|
||||
SUITE,
|
||||
} from "./uniboxHarness";
|
||||
|
||||
type Call = { method?: string; url?: string; data?: Record<string, unknown> };
|
||||
|
||||
const calls = vi.hoisted((): Call[] => []);
|
||||
|
||||
beforeAll(() => {
|
||||
installLayoutShims();
|
||||
setViewportWidth(1512);
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api/client/Request", () => ({
|
||||
default: async (cfg: Call) => {
|
||||
calls.push({ method: cfg.method, url: cfg.url, data: cfg.data });
|
||||
const { route } = await import("./uniboxHarness");
|
||||
return route(String(cfg?.url ?? ""));
|
||||
},
|
||||
}));
|
||||
vi.mock("react-router-dom", async (orig) => {
|
||||
const actual = (await orig()) as typeof ReactRouterDom;
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => {
|
||||
const navigate = actual.useNavigate();
|
||||
return ((...args: Parameters<typeof navigate>) => {
|
||||
setTimeout(() => void navigate(...args), 100);
|
||||
}) as typeof navigate;
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/helper/getToken", () => ({
|
||||
default: () => ({
|
||||
access_token: "a",
|
||||
refresh_token: "r",
|
||||
access_token_expires_at: new Date(Date.now() + 3600e3).toISOString(),
|
||||
refresh_token_expires_at: new Date(Date.now() + 3600e3).toISOString(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/SocketProvider", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("@/hooks/context/socket", async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
useSocket: () => ({
|
||||
isConnected: false,
|
||||
subscribeToChannel: () => () => {},
|
||||
pushToChannel: () => {},
|
||||
socket: null,
|
||||
status: "closed",
|
||||
}),
|
||||
useChannel: () => ({ state: "closed", push: () => {}, channel: null }),
|
||||
useChannelEvent: () => {},
|
||||
useChannelSubscription: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
const seenPatches = () =>
|
||||
calls.filter((c) => c.method === "PATCH" && c.url === "/unibox/seen");
|
||||
|
||||
const rowFor = (i: number) =>
|
||||
screen
|
||||
.getAllByText(ROWS[i].subject)
|
||||
.map((el) => el.closest<HTMLElement>('[role="button"]'))
|
||||
.find((row) => row?.querySelector('[aria-label="Conversation actions"]'))!;
|
||||
|
||||
describe("unibox mark as unread", SUITE, () => {
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
resetScrollTops();
|
||||
setViewportWidth(1512);
|
||||
});
|
||||
|
||||
it("keeps a conversation unread when the reader's button marks it", async () => {
|
||||
const router = await mount(`/app/unibox/awaiting/${ROWS[0].thread_id}`);
|
||||
await settle();
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Mark as unread" })[0]);
|
||||
});
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/app/unibox/awaiting"));
|
||||
const patches = seenPatches();
|
||||
expect(patches.length).toBeGreaterThan(0);
|
||||
expect(patches.every((p) => p.data?.seen === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the open conversation unread when its row menu marks it", async () => {
|
||||
await mount(`/app/unibox/awaiting/${ROWS[0].thread_id}`);
|
||||
await settle();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.contextMenu(rowFor(0), { clientX: 120, clientY: 160 });
|
||||
});
|
||||
await settle();
|
||||
calls.length = 0;
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Mark as unread" }));
|
||||
});
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
const patches = seenPatches();
|
||||
expect(patches.length).toBeGreaterThan(0);
|
||||
expect(patches.every((p) => p.data?.seen === false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// Select mode on a phone: tapping Select by accident has to have a way back,
|
||||
// because there is no Escape key to leave it with.
|
||||
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
|
||||
import { screen, act, fireEvent } from "@testing-library/react";
|
||||
import {
|
||||
installLayoutShims,
|
||||
mount,
|
||||
resetScrollTops,
|
||||
setViewportWidth,
|
||||
settle,
|
||||
SUITE,
|
||||
} from "./uniboxHarness";
|
||||
|
||||
type Call = { method?: string; url?: string; data?: Record<string, unknown> };
|
||||
|
||||
const calls = vi.hoisted((): Call[] => []);
|
||||
|
||||
beforeAll(() => {
|
||||
installLayoutShims();
|
||||
setViewportWidth(390);
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api/client/Request", () => ({
|
||||
default: async (cfg: Call) => {
|
||||
calls.push({ method: cfg.method, url: cfg.url, data: cfg.data });
|
||||
const { route } = await import("./uniboxHarness");
|
||||
return route(String(cfg?.url ?? ""));
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/helper/getToken", () => ({
|
||||
default: () => ({
|
||||
access_token: "a",
|
||||
refresh_token: "r",
|
||||
access_token_expires_at: new Date(Date.now() + 3600e3).toISOString(),
|
||||
refresh_token_expires_at: new Date(Date.now() + 3600e3).toISOString(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/SocketProvider", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("@/hooks/context/socket", async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
useSocket: () => ({
|
||||
isConnected: false,
|
||||
subscribeToChannel: () => () => {},
|
||||
pushToChannel: () => {},
|
||||
socket: null,
|
||||
status: "closed",
|
||||
}),
|
||||
useChannel: () => ({ state: "closed", push: () => {}, channel: null }),
|
||||
useChannelEvent: () => {},
|
||||
useChannelSubscription: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
async function tap(el: Element) {
|
||||
await act(async () => {
|
||||
fireEvent.click(el);
|
||||
});
|
||||
await settle();
|
||||
}
|
||||
|
||||
describe("unibox select mode", SUITE, () => {
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
resetScrollTops();
|
||||
setViewportWidth(390);
|
||||
});
|
||||
|
||||
it("leaves select mode from the header without ticking anything", async () => {
|
||||
await mount("/app/unibox/inbox");
|
||||
await settle();
|
||||
|
||||
await tap(screen.getByRole("button", { name: "Select" }));
|
||||
expect(screen.getByLabelText("Select all loaded")).toBeTruthy();
|
||||
|
||||
await tap(screen.getByRole("button", { name: "Cancel selection" }));
|
||||
expect(screen.queryByLabelText("Select all loaded")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Select" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clears a selection along with the mode", async () => {
|
||||
await mount("/app/unibox/inbox");
|
||||
await settle();
|
||||
|
||||
await tap(screen.getByRole("button", { name: "Select" }));
|
||||
await tap(screen.getByLabelText("Select all loaded"));
|
||||
expect(screen.getByRole("toolbar", { name: "Selection actions" })).toBeTruthy();
|
||||
|
||||
await tap(screen.getByRole("button", { name: "Cancel selection" }));
|
||||
await settle();
|
||||
expect(screen.queryByRole("toolbar", { name: "Selection actions" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Select" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CheckIcon, PanelLeftIcon, SearchIcon } from "lucide-react";
|
||||
import { CheckIcon, PanelLeftIcon, SearchIcon, XIcon } from "lucide-react";
|
||||
import { AnimatedRow } from "./AnimatedRow";
|
||||
import { ConversationItem } from "./ConversationItem";
|
||||
import { SelectionBar } from "./SelectionBar";
|
||||
@@ -439,14 +439,26 @@ export function ConversationList({
|
||||
device hovering a row already shows its box, but the control is
|
||||
kept everywhere so the feature is discoverable at all. */}
|
||||
{selecting ? (
|
||||
<label className="h-7 px-2 rounded-md inline-flex items-center gap-1.5 text-[11.5px] text-slate-600 hover:bg-slate-100 cursor-pointer transition-colors shrink-0">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
aria-label={allSelected ? "Deselect all" : "Select all loaded"}
|
||||
/>
|
||||
All
|
||||
</label>
|
||||
<span className="inline-flex items-center shrink-0">
|
||||
<label className="h-7 px-2 rounded-md inline-flex items-center gap-1.5 text-[11.5px] text-slate-600 hover:bg-slate-100 cursor-pointer transition-colors">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
aria-label={allSelected ? "Deselect all" : "Select all loaded"}
|
||||
/>
|
||||
All
|
||||
</label>
|
||||
{/* The way out on a phone, where there is no Escape. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
aria-label="Cancel selection"
|
||||
title="Cancel selection (Esc)"
|
||||
className="h-7 w-7 rounded-md inline-flex items-center justify-center text-slate-400 hover:text-slate-900 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<XIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -237,13 +237,17 @@ export function ThreadView({ threadId, emailId, onClose }: ThreadViewProps) {
|
||||
// is seen the id list is empty and this no-ops, so it self-terminates.
|
||||
const markSeen = useMarkSeen();
|
||||
const markSeenMutate = markSeen.mutate;
|
||||
// The reader outlives a close by a render (the URL follows the store in an
|
||||
// effect), and reading back the patch Mark as unread just wrote undoes it.
|
||||
const selected = useAppStore((s) => s.selectedThreadId === threadId);
|
||||
React.useEffect(() => {
|
||||
if (!selected) return;
|
||||
const unseenIds = (q.data?.data ?? [])
|
||||
.filter((m) => !m.seen)
|
||||
.map((m) => m.id);
|
||||
if (unseenIds.length === 0) return;
|
||||
markSeenMutate({ ids: unseenIds, threadIds: [threadId] });
|
||||
}, [threadId, q.data, markSeenMutate]);
|
||||
}, [selected, threadId, q.data, markSeenMutate]);
|
||||
|
||||
// Header actions. Each one closes the thread: the effect above would
|
||||
// otherwise re-mark an "unread" thread as seen on the next refetch, and a
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
unsnoozeThreads,
|
||||
} from "@/lib/api/client/app/unibox/snoozeThread";
|
||||
import { SNOOZE_MAX_MS } from "@/lib/unibox/snooze";
|
||||
import { useAppStore } from "@/stores";
|
||||
|
||||
// Filing copy, per destination. "Deleted" is deliberately not said anywhere:
|
||||
// Delete moves the message to Trash, here and in the mailbox.
|
||||
@@ -110,6 +111,11 @@ export function useConversationActions(): ConversationActions {
|
||||
const setSeen = React.useCallback(
|
||||
(threadIds: string[], seen: boolean) => {
|
||||
if (threadIds.length === 0) return;
|
||||
// An open reader marks what it shows as read, so unread closes it.
|
||||
const store = useAppStore.getState();
|
||||
if (!seen && store.selectedThreadId && threadIds.includes(store.selectedThreadId)) {
|
||||
store.setSelectedThreadId(null);
|
||||
}
|
||||
markSeen.mutate({ threadIds, seen });
|
||||
if (threadIds.length > 1) {
|
||||
toast.success(
|
||||
|
||||
Reference in New Issue
Block a user