feat: let plugins refresh their message-list badges, which now need only the read permission

This commit is contained in:
Linus Rath
2026-10-14 06:26:10 +02:00
parent 11b1ec2fa5
commit 1f89e89ce5
6 changed files with 69 additions and 5 deletions
@@ -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' }]);
+7 -4
View File
@@ -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<string, EmailListBadge[]>();
const inflight = new Map<string, Promise<EmailListBadge[]>>();
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) ?? [] : [],
+1 -1
View File
@@ -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'),
+4
View File
@@ -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<Record<string, Permission | null>> = {
// 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;
+36
View File
@@ -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;
}
+5
View File
@@ -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<void>,
/**
* 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<void>,
/** 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<void>,