From b4312d7ec1cd888ec9be4943170f1dffba56b832 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 30 Sep 2026 04:57:00 -0700 Subject: [PATCH] feat: keep favorites starred during a Favorites edit, re-highlight in Favorites after a scope is reached by shortcut or Back, name the target of a renamed view on hover, word the hide toast by where the highlight is, route the edit-mode star through the menu's add, cap favorite names by code point, and memoize the mailbox, label and tag rail rows --- web/src/components/app/unibox/ScopeRail.tsx | 69 +++++++++++-------- .../components/app/unibox/scopeRail.test.tsx | 56 +++++++++++++++ web/src/stores/slices/uiSlice.ts | 4 +- 3 files changed, 100 insertions(+), 29 deletions(-) diff --git a/web/src/components/app/unibox/ScopeRail.tsx b/web/src/components/app/unibox/ScopeRail.tsx index ad4783457..63eed4452 100644 --- a/web/src/components/app/unibox/ScopeRail.tsx +++ b/web/src/components/app/unibox/ScopeRail.tsx @@ -243,6 +243,12 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { ); const active = scopeKey(scope); + // A scope reached any other way (shortcut, Back) highlights in Favorites again. + const [pickedFor, setPickedFor] = React.useState(active); + if (pickedFor !== active) { + setPickedFor(active); + if (homePick !== null && homePick !== active) setHomePick(null); + } const folderCounts = React.useMemo(() => { const m = new Map(); for (const f of data?.folders ?? []) { @@ -327,7 +333,10 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { }) : []; - const mailboxRows: RailRow[] = (data?.mailboxes ?? []).map((m) => ({ + const mailboxes = data?.mailboxes; + const categories = data?.categories; + const tags = data?.tags; + const mailboxRows = React.useMemo(() => (mailboxes ?? []).map((m) => ({ key: `mailbox:${m.id}`, label: m.email, icon: , @@ -336,9 +345,9 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { noun: "mailbox", search: `${m.email} ${m.name}`, onOpen: () => onChange({ kind: "mailbox", mailboxId: m.id }), - })); + })), [mailboxes, onChange]); - const labelRows: RailRow[] = (data?.categories ?? []).map((c) => ({ + const labelRows = React.useMemo(() => (categories ?? []).map((c) => ({ key: `category:${c.id}`, label: c.title, icon: , @@ -347,9 +356,9 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { noun: "label", tooltip: tagMeaning(c.title) || undefined, onOpen: () => onChange({ kind: "category", categoryId: c.id }), - })); + })), [categories, onChange]); - const tagRows: RailRow[] = (data?.tags ?? []).map((t) => ({ + const tagRows = React.useMemo(() => (tags ?? []).map((t) => ({ key: `tag:${t.id}`, label: t.title, icon: , @@ -357,13 +366,16 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { accent: t.unread > 0, noun: "tag", onOpen: () => onChange({ kind: "tag", tagId: t.id }), - })); + })), [tags, onChange]); // Favorites resolve against every other section, so a row always carries its live count. const everyRow = new Map( [...mailRows, ...viewRows, ...mailboxRows, ...labelRows, ...tagRows].map((r) => [r.key, r]), ); - const favoriteList = favoritesDraft ?? favorites; + // A favorite added while editing (a keyboard star elsewhere) joins the draft at the end. + const favoriteList = favoritesDraft + ? [...favoritesDraft, ...favorites.filter((f) => !favoritesDraft.some((d) => d.key === f.key))] + : favorites; const favoriteKeys = new Set(favorites.map((f) => f.key)); const favoriteRows: RailRow[] = favoriteList.flatMap((f) => { const r = everyRow.get(f.key); @@ -373,6 +385,7 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { ...r, label: f.name ?? r.label, original: f.name ? r.label : undefined, + tooltip: f.name && r.tooltip ? `${r.label}: ${r.tooltip}` : r.tooltip, noun: "favorite", onOpen: () => { setHomePick(null); @@ -385,14 +398,20 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { // One row carries the highlight: the favorite, unless the scope was opened from its own section. const inFavorites = favoriteKeys.has(active) && homePick !== active && favoriteRows.some((r) => r.key === active); const homeActive = inFavorites ? "" : active; - const home = (rows: RailRow[]) => - rows.map((r) => ({ - ...r, - onOpen: () => { - setHomePick(r.key); - r.onOpen(); - }, - })); + const home = React.useCallback( + (rows: RailRow[]) => + rows.map((r) => ({ + ...r, + onOpen: () => { + setHomePick(r.key); + r.onOpen(); + }, + })), + [], + ); + const homeMailboxRows = React.useMemo(() => home(mailboxRows), [home, mailboxRows]); + const homeLabelRows = React.useMemo(() => home(labelRows), [home, labelRows]); + const homeTagRows = React.useMemo(() => home(tagRows), [home, tagRows]); // Writes the favorites; while editing, the draft keeps unticked ones in place. const writeFavorites = (next: UniboxRailFavorite[], kept = favoriteKeys) => { @@ -451,13 +470,13 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { (favoriteKeys.has(row.key) ? removeFavorite(row.key, row.label) : toggleFavorite(row.key))} + onToggle={() => (favoriteKeys.has(row.key) ? removeFavorite(row.key, row.label) : addFavorite(row))} /> ); const hideWithUndo = (row: RailRow) => { setRowsHidden([row.key], true); - const when = row.key === active ? " once you leave it" : ""; + const when = row.key === homeActive ? " once you leave it" : ""; undoToast(`rail-hidden:${row.key}`, `${row.label} is hidden from the rail${when}.`, () => setRowsHidden([row.key], false), ); @@ -602,7 +621,7 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { id="mailboxes" label="Mailboxes" controls={controls("mailboxes")} - rows={home(mailboxRows)} + rows={homeMailboxRows} activeKey={homeActive} rowMenu={favoriteItem} emptyText={overview.isPending ? "Loading…" : "No mailboxes connected."} @@ -615,7 +634,7 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { id="labels" label="Labels" controls={controls("labels")} - rows={home(labelRows)} + rows={homeLabelRows} activeKey={homeActive} rowMenu={favoriteItem} emptyText="No labels yet." @@ -628,7 +647,7 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { id="tags" label="Tags" controls={controls("tags")} - rows={home(tagRows)} + rows={homeTagRows} activeKey={homeActive} rowMenu={favoriteItem} emptyText="No tags yet." @@ -976,8 +995,7 @@ function MoveItems({ row, ctx }: { row: RailRow; ctx: RowContext }) { ); } -// FixedSection: Mail and Views, a shipped list of rows the user can hide, -// reorder and star into Favorites. +// FixedSection: Mail and Views, whose shipped rows can be hidden, reordered and starred. function FixedSection({ id, label, @@ -1061,9 +1079,7 @@ function FixedSection({ ); } -// RailSection: an ordered list of rows that folds and has an edit mode, where -// each row can be ticked off (hidden, or out of Favorites) and reordered. The -// row you are on is never ticked out of sight. +// RailSection: ordered rows that fold, reorder and tick off in edit mode; the active row always shows. function RailSection({ id, label, @@ -1796,8 +1812,7 @@ function FavoriteToggle({ on, label, onToggle }: { on: boolean; label: string; o ); } -// RenameRow: a favorite's name, edited in place. Enter or leaving the field -// keeps it, Escape drops the edit, and an empty name goes back to the row's own. +// RenameRow: Enter or blur keeps the name, Escape drops it, empty restores the row's own. function RenameRow({ row, onDone, diff --git a/web/src/components/app/unibox/scopeRail.test.tsx b/web/src/components/app/unibox/scopeRail.test.tsx index 94b66e083..cc9016b24 100644 --- a/web/src/components/app/unibox/scopeRail.test.tsx +++ b/web/src/components/app/unibox/scopeRail.test.tsx @@ -738,6 +738,30 @@ describe("Favorites", () => { expect(favoritesPanel().contains(current[0])).toBe(true); }); + it("highlights in Favorites again once the scope is left and reached some other way", () => { + useAppStore.setState({ uniboxRailFavorites: [{ key: "folder:inbox" }] }); + const onChange = vi.fn(); + const { rerender } = render(); + const homeInbox = () => screen.getAllByText("Inbox").map((n) => n.closest("[data-rail-row]") as HTMLElement)[1]; + fireEvent.click(homeInbox()); + rerender(); + expect(favoritesPanel().querySelector("[aria-current]")).toBeNull(); + + // A shortcut away and back, with no click in the rail. + rerender(); + rerender(); + expect(favoritesPanel().querySelector("[aria-current]")).toBeTruthy(); + expect(document.querySelectorAll("[aria-current]")).toHaveLength(1); + }); + + it("names what a renamed view points at on hover", () => { + useAppStore.setState({ uniboxRailFavorites: [{ key: "view:needs_reply", name: "Follow-ups" }] }); + mountRail(); + expect(favoriteLabels()).toEqual(["Follow-ups"]); + fireEvent.focus(favoritesPanel().querySelector("[data-rail-row]") as HTMLElement); + expect(screen.getAllByText(/^Needs a reply: /).length).toBeGreaterThan(0); + }); + it("folds, keeps the favorite you are on, and remembers the fold", () => { useAppStore.setState({ uniboxRailFavorites: [{ key: "folder:inbox" }, { key: "unread" }] }); mountRail({ kind: "unread" }); @@ -872,6 +896,17 @@ describe("Favorites edit mode", () => { expect(favoriteLabels()).toEqual(["Work", "Hot leads"]); }); + it("keeps a favorite starred elsewhere while editing when the section next writes", () => { + useAppStore.setState({ uniboxRailFavorites: [{ key: "unread" }, { key: "view:hot" }] }); + mountRail(); + fireEvent.click(screen.getByRole("button", { name: "Edit favorites" })); + // Starred from another row's menu by keyboard, which leaves edit mode open. + act(() => useAppStore.getState().toggleUniboxRailFavorite("folder:sent")); + fireEvent.click(screen.getByRole("checkbox", { name: "Unread" })); + expect(useAppStore.getState().uniboxRailFavorites.map((f) => f.key)).toEqual(["view:hot", "folder:sent"]); + expect(screen.getByRole("checkbox", { name: "Sent" }).getAttribute("aria-checked")).toBe("true"); + }); + it("stays on the rail while every favorite is unticked, and leaves it on Done", () => { useAppStore.setState({ uniboxRailFavorites: [{ key: "unread" }] }); mountRail(); @@ -929,6 +964,23 @@ describe("Favorites edit mode", () => { }); }); +describe("Favorites and hiding", () => { + it("says a row hides at once when the scope you are on is highlighted in Favorites", () => { + useAppStore.setState({ uniboxRailFavorites: [{ key: "folder:inbox" }] }); + render( + <> + {}} /> + + , + ); + fireEvent.click(screen.getByLabelText("Inbox folder actions")); + fireEvent.click(screen.getByRole("menuitem", { name: "Hide from rail" })); + expect(screen.getByText("Inbox is hidden from the rail.")).toBeTruthy(); + // Gone from Mail, still in Favorites. + expect(screen.getAllByText("Inbox")).toHaveLength(1); + }); +}); + describe("Favorites undo", () => { it("puts a removed favorite back where it was, with its name", () => { useAppStore.setState({ @@ -992,6 +1044,10 @@ describe("sanitizeUniboxRailFavorites", () => { expect(cleanUniboxRailFavoriteName("a\n\tb")).toBe("a b"); expect(cleanUniboxRailFavoriteName("x".repeat(80))).toHaveLength(40); expect(cleanUniboxRailFavoriteName("")).toBeUndefined(); + // An emoji straddling the cap is kept whole or dropped, never split. + const capped = cleanUniboxRailFavoriteName(`${"x".repeat(39)}🔥🔥`) as string; + expect(Array.from(capped)).toHaveLength(40); + expect(capped.endsWith("🔥")).toBe(true); }); it("runs on rehydration", async () => { diff --git a/web/src/stores/slices/uiSlice.ts b/web/src/stores/slices/uiSlice.ts index 580c86ea7..f16687d5e 100644 --- a/web/src/stores/slices/uiSlice.ts +++ b/web/src/stores/slices/uiSlice.ts @@ -134,10 +134,10 @@ export const sanitizeUniboxRailOrder = (v: unknown): Record => ) } -// A blank name means "use the row's own label"; anything else is trimmed and capped. +// A blank name means "use the row's own label"; anything else is trimmed and capped by code point. export const cleanUniboxRailFavoriteName = (v: unknown): string | undefined => { if (typeof v !== 'string') return undefined - const name = v.replace(/\s+/g, ' ').trim().slice(0, UNIBOX_RAIL_FAVORITE_NAME_MAX).trim() + const name = Array.from(v.replace(/\s+/g, ' ').trim()).slice(0, UNIBOX_RAIL_FAVORITE_NAME_MAX).join('').trim() return name || undefined }