mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let plugins refresh their message-list badges, which now need only the read permission
This commit is contained in:
@@ -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' }]);
|
||||
|
||||
@@ -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) ?? [] : [],
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user