'use client'; import { useMemo, useSyncExternalStore, useState } from 'react'; import { useTranslations } from 'next-intl'; import { iconForName, MoreHorizontal, Puzzle } from '@/components/icons'; import { ContextMenu, ContextMenuItem } from '@/components/ui/context-menu'; import { useContextMenu } from '@/hooks/use-context-menu'; import { cn } from '@/lib/utils'; import { actionApplies, actionsFor, badgeText, runAction, subscribeActions, type ActionFilter, type RegisteredAction, } from '@/lib/plugin-sandbox/actions'; import { mailAccountKind, useConfirmedGroups, type MailAccountKind } from '@/lib/plugin-sandbox/account-kind'; import type { Email } from '@/lib/jmap/types'; import { useAuthStore } from '@/stores/auth-store'; import { resolveEmailBlobContext, useEmailStore } from '@/stores/email-store'; const EMPTY: RegisteredAction[] = []; /** The actions plugins declared for a surface, filtered by folder role, MIME type, account and keywords. */ export function usePluginActions(surface: string, filter: ActionFilter = {}) { const all = useSyncExternalStore(subscribeActions, () => actionsFor(surface), () => EMPTY); return all.filter((a) => actionApplies(a, filter)); } /** The message an action surface is about: its account and keywords. */ export interface MessageActionTarget { /** The JMAP account the message is in, when it isn't the signed-in account's own. */ accountId?: string; accountKind: MailAccountKind | null; keywords: string[] | null; } type ActionEmail = Pick; /** * The account and keywords of `email`, for the actions' `accounts`, * `hasKeyword` and `notKeyword` and the click's `accountId`. Follows the * server's answer on which accounts are groups, so a team mailbox's * actions appear once it is in. */ export function useMessageActionTarget(email: ActionEmail | null | undefined): MessageActionTarget { const groups = useConfirmedGroups((s) => s.revision); const client = useAuthStore((s) => s.client); const selectedMailbox = useEmailStore((s) => s.selectedMailbox); const mailboxes = useEmailStore((s) => s.mailboxes); const keywords = email?.keywords; const keywordKey = keywords ? Object.keys(keywords).filter((k) => keywords[k]).sort().join(' ') : ''; const sourceAccountId = email?.sourceAccountId; const sourceClientAccountId = email?.sourceClientAccountId; const hasEmail = !!email; return useMemo(() => { if (!hasEmail) return { accountKind: null, keywords: null }; const owner = resolveEmailBlobContext({ sourceAccountId, sourceClientAccountId }, client); const own = owner.client?.getAccountId?.(); const accountId = owner.accountId && owner.accountId !== own ? owner.accountId : undefined; return { ...(accountId ? { accountId } : {}), accountKind: owner.client ? mailAccountKind(owner.client, owner.accountId) : null, keywords: keywordKey ? keywordKey.split(' ') : [], }; // The group lists, the open folder and its mailboxes decide the account // (resolveEmailBlobContext reads them from the stores). // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasEmail, keywordKey, sourceAccountId, sourceClientAccountId, client, groups, selectedMailbox, mailboxes]); } /** The account kind of the open folder, for surfaces about the selection (message-bulk-actions). */ export function useFolderAccountKind(): MailAccountKind | null { const groups = useConfirmedGroups((s) => s.revision); const client = useAuthStore((s) => s.client); const selectedMailbox = useEmailStore((s) => s.selectedMailbox); const mailboxes = useEmailStore((s) => s.mailboxes); return useMemo(() => { const owner = resolveEmailBlobContext({}, client); return owner.client ? mailAccountKind(owner.client, owner.accountId) : null; // See useMessageActionTarget. // eslint-disable-next-line react-hooks/exhaustive-deps }, [client, groups, selectedMailbox, mailboxes]); } /** The context a message action's click carries: the surface's, plus the message's account. */ function withTarget(context: Record, target: MessageActionTarget | undefined): Record { return target?.accountId ? { ...context, accountId: target.accountId } : context; } function targetFilter(target: MessageActionTarget | undefined, accountKind: MailAccountKind | null | undefined): Pick { return { accountKind: target ? target.accountKind : accountKind ?? null, keywords: target?.keywords ?? null }; } interface PluginActionsProps { /** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */ surface: string; /** What the click is about (ids of the message, files, ...). Filtered per plugin before it is sent. */ context: Record; /** Buttons in a toolbar row, or entries in a menu. */ variant?: 'buttons' | 'menu'; mailboxRole?: string | null; mimeType?: string | null; className?: string; /** Icon-only buttons with the label as tooltip (narrow toolbars). */ compact?: boolean; /** No room for buttons: one "more" button that opens the actions as a menu. */ overflow?: boolean; /** Called after a click, e.g. to close the menu the entry sits in. */ onRan?: () => void; /** The message the surface is about (useMessageActionTarget). */ target?: MessageActionTarget; /** Surfaces about the selection: the open folder's account kind. */ accountKind?: MailAccountKind | null; } /** * Buttons or menu entries plugins declared with api.ui.setActions. The host * draws them so they match the app; the plugin gets the click. */ export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, overflow, onRan, target, accountKind }: PluginActionsProps) { const actions = usePluginActions(surface, { mailboxRole, mimeType, ...targetFilter(target, accountKind) }); const [busy, setBusy] = useState(null); if (actions.length === 0) return null; if (overflow && variant === 'buttons') { return ; } const run = async (action: RegisteredAction) => { const key = `${action.pluginId}:${action.id}`; setBusy(key); try { onRan?.(); await runAction(action, surface, withTarget(context, target)); } finally { setBusy((current) => (current === key ? null : current)); } }; if (variant === 'menu') { return ( <> {actions.map((action) => ( { void run(action); }} /> ))} ); } return (
{actions.map((action) => { const Icon = iconForName(action.icon) ?? Puzzle; const key = `${action.pluginId}:${action.id}`; return ( ); })}
); } /** The actions of a surface behind one "more" button, for toolbars without room for them. */ function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, className, target, accountKind }: Pick) { const t = useTranslations('email_viewer'); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); return (
); }