From 1f89e89ce5423b93a84ee143bdffe0854958cd56 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:26:10 +0200 Subject: [PATCH] feat: let plugins refresh their message-list badges, which now need only the read permission --- .../__tests__/plugin-row-badges.test.tsx | 16 +++++++++ components/email/plugin-row-badges.tsx | 11 +++--- lib/plugin-hook-catalog.ts | 2 +- lib/plugin-sandbox/host-api.ts | 4 +++ lib/plugin-sandbox/row-badges.ts | 36 +++++++++++++++++++ lib/plugin-sandbox/runtime.tsx | 5 +++ 6 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 lib/plugin-sandbox/row-badges.ts diff --git a/components/email/__tests__/plugin-row-badges.test.tsx b/components/email/__tests__/plugin-row-badges.test.tsx index f4406bac2..149d9a559 100644 --- a/components/email/__tests__/plugin-row-badges.test.tsx +++ b/components/email/__tests__/plugin-row-badges.test.tsx @@ -7,6 +7,7 @@ import { useEmailStore } from '@/stores/email-store'; import { useUIStore } from '@/stores/ui-store'; import { groupEmailsByThread } from '@/lib/thread-utils'; import { renderHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import { refreshRowBadges, resetRowBadgeRefresh } from '@/lib/plugin-sandbox/row-badges'; import type { Email } from '@/lib/jmap/types'; // onEmailListItemRender: plugins add small badges next to a row's subject. @@ -56,6 +57,7 @@ function row(emails: Email[]) { beforeEach(() => { clearAllHooks(); clearRowBadgeCache(); + resetRowBadgeRefresh(); useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], @@ -87,6 +89,20 @@ describe('plugin badges in message-list rows', () => { expect(ctx.email).not.toHaveProperty('mailboxIds.inbox'); }); + it('asks the plugin again when it requests a refresh, at most once a second', async () => { + let label = 'Open'; + const handler = vi.fn(async () => [{ key: 'task', label }]); + renderHooks.onEmailListItemRender.register('test', handler); + render(row([makeEmail()])); + await screen.findByText('Open'); + + label = 'Done'; + act(() => { expect(refreshRowBadges(10_000)).toBe(true); }); + await screen.findByText('Done'); + expect(refreshRowBadges(10_500)).toBe(false); + expect(handler).toHaveBeenCalledTimes(2); + }); + it('shows badges on a conversation row and on the phone layout', async () => { useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false }); renderHooks.onEmailListItemRender.register('test', async () => [{ key: 'k', label: 'Phone badge' }]); diff --git a/components/email/plugin-row-badges.tsx b/components/email/plugin-row-badges.tsx index 03e28b3f2..de6b0e787 100644 --- a/components/email/plugin-row-badges.tsx +++ b/components/email/plugin-row-badges.tsx @@ -1,10 +1,11 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; import type { Email } from "@/lib/jmap/types"; import type { EmailListBadge } from "@/lib/plugin-types"; import { renderHooks } from "@/lib/plugin-hooks"; import { emailSummary } from "@/lib/plugin-projection"; +import { rowBadgeGeneration, subscribeRowBadges } from "@/lib/plugin-sandbox/row-badges"; import { cn } from "@/lib/utils"; import { TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge"; @@ -24,14 +25,15 @@ const CACHE_LIMIT = 2000; const cache = new Map(); const inflight = new Map>(); -function cacheKey(email: Email, listeners: number): string { +function cacheKey(email: Email, listeners: number, generation: number): string { const keywords = Object.keys(email.keywords ?? {}) .filter((k) => email.keywords?.[k]) .sort() .join(","); // The listener count is part of the key so a plugin being switched on or // off doesn't leave the old answer behind. - return `${listeners}|${email.id}|${keywords}`; + // The generation moves on when a plugin asks for a refresh. + return `${generation}|${listeners}|${email.id}|${keywords}`; } // A colour the badge may use: hex, rgb()/hsl() with plain numbers, a CSS @@ -104,7 +106,8 @@ export function clearRowBadgeCache(): void { */ export function usePluginRowBadges(email: Email): EmailListBadge[] { const listeners = renderHooks.onEmailListItemRender.size; - const key = listeners > 0 ? cacheKey(email, listeners) : null; + const generation = useSyncExternalStore(subscribeRowBadges, rowBadgeGeneration, rowBadgeGeneration); + const key = listeners > 0 ? cacheKey(email, listeners, generation) : null; const [state, setState] = useState<{ key: string | null; badges: EmailListBadge[] }>(() => ({ key, badges: key ? cache.get(key) ?? [] : [], diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts index 3f09f8f43..de80235bf 100644 --- a/lib/plugin-hook-catalog.ts +++ b/lib/plugin-hook-catalog.ts @@ -44,7 +44,7 @@ const H = (group: HookGroup, kind: HookKind, permission: Permission, payload: st export const HOOK_CATALOG = { // ─── Mail: message list ──────────────────────────────────── onEmailsFetched: H('mail-list', 'transform', 'email:render-takeover', 'Email[] (list rows), { mailboxId }', 'after a page of the message list is loaded, before it is shown'), - onEmailListItemRender: H('mail-list', 'transform', 'email:render-takeover', 'EmailListBadge[], { email: EmailSummary }', 'for each visible row; returned badges are shown next to the subject'), + onEmailListItemRender: H('mail-list', 'transform', 'email:read', 'EmailListBadge[], { email: EmailSummary }', 'for each visible row; returned badges are shown next to the subject'), onEmailSelectionChange: H('mail-list', 'observer', 'email:read', '{ emailIds: string[] }', 'when the set of selected messages changes'), onMailboxChange: H('mail-list', 'observer', 'email:read', '{ mailboxId, role, previousMailboxId }', 'when the user opens another folder'), diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index b8ae7ab79..3b055baeb 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -7,6 +7,7 @@ import { mayOfferSlot, pluginHasPermission } from './permissions'; import { proxyAccounts, proxyCancel, proxyConnect, proxyDisconnect, proxyFetch, type StreamSink } from './proxy-client'; import { checkActions, isActionSurface, setPluginActions } from './actions'; import { checkDayLabels, setDayLabels } from './day-labels'; +import { refreshRowBadges } from './row-badges'; import { checkPages, setPluginPages } from './pages'; import { toast as appToast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; @@ -200,6 +201,8 @@ export const PERM_PER_METHOD: Readonly> = { // Buttons and menu entries drawn by the host; the surface's own // permission is checked per call (see the slot catalogue). 'ui.setActions': null, + // Ask the rows again for onEmailListItemRender badges (rate limited). + 'ui.refreshRowBadges': null, 'ui.setDayLabels': 'ui:calendar-action', 'ui.setPages': 'ui:app-page', // sieve (delivery-time classification) @@ -1687,6 +1690,7 @@ export async function dispatchApiCall( setPluginActions(plugin.id, plugin.name, surface, checkActions(args[1])); return undefined; } + case 'ui.refreshRowBadges': refreshRowBadges(); return undefined; case 'ui.setDayLabels': setDayLabels(plugin.id, plugin.name, checkDayLabels(args[0])); return undefined; case 'ui.setPages': setPluginPages(plugin.id, plugin.name, checkPages(args[0])); return undefined; diff --git a/lib/plugin-sandbox/row-badges.ts b/lib/plugin-sandbox/row-badges.ts new file mode 100644 index 000000000..af35949f5 --- /dev/null +++ b/lib/plugin-sandbox/row-badges.ts @@ -0,0 +1,36 @@ +// Plugins' row badges (onEmailListItemRender) are cached per message and +// keyword state. A plugin whose answer changed for another reason (a task +// was created elsewhere) asks for a refresh with api.ui.refreshRowBadges(). + +let generation = 0; +let lastRefresh = 0; +const listeners = new Set<() => void>(); + +/** How often a refresh may happen. */ +export const ROW_BADGE_REFRESH_INTERVAL_MS = 1000; + +export function rowBadgeGeneration(): number { + return generation; +} + +export function subscribeRowBadges(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +/** Ask every visible row again. At most once a second; extra calls are dropped. */ +export function refreshRowBadges(now = Date.now()): boolean { + if (now - lastRefresh < ROW_BADGE_REFRESH_INTERVAL_MS) return false; + lastRefresh = now; + generation += 1; + for (const l of listeners) { + try { l(); } catch { /* ignore */ } + } + return true; +} + +/** Test helper. */ +export function resetRowBadgeRefresh(): void { + generation = 0; + lastRefresh = 0; +} diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index dacd77481..4afaef850 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -500,6 +500,11 @@ function buildPluginApi(manifest: PluginManifest) { */ setActions: (surface: string, actions: Array<{ id: string; label: string; icon?: string; order?: number; mailboxRoles?: string[]; mimeTypes?: string[]; danger?: boolean }>) => callApi('ui.setActions', [surface, actions]) as Promise, + /** + * Ask every visible message row for its onEmailListItemRender badges + * again, e.g. after a task was created elsewhere. At most once a second. + */ + refreshRowBadges: () => callApi('ui.refreshRowBadges', []) as Promise, /** Short labels and dots on calendar days. Replaces this plugin's earlier labels. */ setDayLabels: (labels: Array<{ date: string; text?: string; title?: string; dot?: boolean }>) => callApi('ui.setDayLabels', [labels]) as Promise,