diff --git a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
new file mode 100644
index 000000000..06e07cf7d
--- /dev/null
+++ b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
@@ -0,0 +1,186 @@
+import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { EventModal } from '../event-modal';
+import { EventDetailPopover } from '../event-detail-popover';
+import { CalendarWeekView } from '../calendar-week-view';
+import { CalendarDayView } from '../calendar-day-view';
+import { CalendarMonthView } from '../calendar-month-view';
+import { MiniCalendar } from '../mini-calendar';
+import { resetDayLabels, setDayLabels } from '@/lib/plugin-sandbox/day-labels';
+import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
+import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
+
+// Plugin surfaces in the calendar: a section in an event's details and in
+// its form, and the labels and dots plugins put on days.
+
+vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
+
+const calendars = [
+ { id: 'cal-1', name: 'Work', color: '#123456', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } },
+] as unknown as Calendar[];
+
+const existing = {
+ id: 'e1',
+ uid: 'uid-1',
+ title: 'Planning',
+ start: '2026-10-12T10:00:00',
+ duration: 'PT1H',
+ timeZone: null,
+ showWithoutTime: false,
+ calendarIds: { 'cal-1': true },
+} as unknown as CalendarEvent;
+
+afterEach(() => {
+ cleanup();
+ clearSlotOffers();
+ resetDayLabels();
+});
+
+describe('calendar-event-detail', () => {
+ it('shows the event in the detail popover', () => {
+ offerSlots('p1', ['calendar-event-detail']);
+ render(
+ ,
+ );
+
+ const frame = screen.getByTestId('plugin-frame-calendar-event-detail');
+ expect(framePropsOf(frame).event).toMatchObject({ id: 'e1', uid: 'uid-1', title: 'Planning', calendarId: 'cal-1' });
+ });
+
+ it('shows the event in the modal view, and not in the form', () => {
+ offerSlots('p1', ['calendar-event-detail']);
+ render();
+
+ expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-detail')).event).toMatchObject({ id: 'e1', title: 'Planning' });
+
+ fireEvent.click(screen.getByText('events.edit'));
+ expect(screen.queryByTestId('plugin-frame-calendar-event-detail')).toBeNull();
+ });
+
+ it('renders nothing when no plugin offers it', () => {
+ render();
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ });
+});
+
+describe('calendar-event-form', () => {
+ it('hands the plugin the form state of an edited event and follows the fields', async () => {
+ offerSlots('p1', ['calendar-event-form']);
+ render();
+ expect(screen.queryByTestId('plugin-frame-calendar-event-form')).toBeNull();
+
+ fireEvent.click(screen.getByText('events.edit'));
+ const frame = screen.getByTestId('plugin-frame-calendar-event-form');
+ expect(framePropsOf(frame).event).toMatchObject({
+ id: 'e1', uid: 'uid-1', calendarId: 'cal-1', title: 'Planning', start: '2026-10-12T10:00:00', duration: 'PT1H', isAllDay: false,
+ });
+
+ fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Planning v2' } });
+ await waitFor(() => {
+ expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({ title: 'Planning v2' });
+ });
+ });
+
+ it('gives a new event an empty id', () => {
+ offerSlots('p1', ['calendar-event-form']);
+ render(
+ ,
+ );
+
+ expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({
+ id: '', uid: '', calendarId: 'cal-1', duration: 'PT1H30M', participants: [],
+ });
+ });
+
+ it('renders nothing when no plugin offers it', () => {
+ render();
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ });
+});
+
+describe('calendar-day-header and mini-calendar-marks', () => {
+ const week = {
+ focus: { date: new Date(2026, 8, 9), nonce: 0 },
+ windowKey: 'week:2026-09-07',
+ rangeStart: new Date(2026, 8, 7),
+ rangeEnd: new Date(2026, 8, 13),
+ selectedDate: new Date(2026, 8, 9),
+ events: [],
+ calendars,
+ onSelectDate: vi.fn(),
+ onSelectEvent: vi.fn(),
+ onCreateAtTime: vi.fn(),
+ };
+
+ function headerOf(dayKey: string): HTMLElement {
+ const header = document.querySelector(`[role="columnheader"][data-day="${dayKey}"], [data-day="${dayKey}"] [role="columnheader"]`);
+ if (!header) throw new Error(`no header for ${dayKey}`);
+ return header;
+ }
+
+ it('puts a label under the day name in the week view', () => {
+ render();
+ expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
+
+ act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day', title: 'A feast' }]));
+
+ const label = headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]');
+ expect(label).toHaveTextContent('Feast day');
+ expect(label?.title).toBe('A feast (Holidays)');
+ expect(headerOf('2026-09-10').querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
+ });
+
+ it('puts a label under the day name in the day view', () => {
+ act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }]));
+ render();
+
+ expect(headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]')).toHaveTextContent('Feast day');
+ });
+
+ it('shows the label in the month cell header on desktop only', () => {
+ act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }]));
+ const month = { ...week, windowKey: 'month:2026-09-09', rangeStart: new Date(2026, 7, 31), rangeEnd: new Date(2026, 9, 4) };
+ const { unmount } = render();
+
+ const labels = document.querySelectorAll('[data-plugin-surface="calendar-day-header"]');
+ expect(labels).toHaveLength(1);
+ expect(labels[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/);
+ unmount();
+
+ render();
+ expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
+ });
+
+ it('marks days with a dot in the small month calendar', () => {
+ render(
+ ,
+ );
+ expect(document.querySelector('[data-plugin-surface="mini-calendar-marks"]')).toBeNull();
+
+ act(() => setDayLabels('hol', 'Holidays', [
+ { date: '2026-09-09', dot: true, title: 'A feast' },
+ { date: '2026-09-10', text: 'Text only' },
+ ]));
+
+ const dots = document.querySelectorAll('[data-plugin-surface="mini-calendar-marks"]');
+ expect(dots).toHaveLength(1);
+ expect(dots[0].closest('button')).toHaveTextContent('9');
+ expect(dots[0].closest('button')).toHaveTextContent('A feast');
+ });
+});
diff --git a/components/calendar/__tests__/task-plugin-surfaces.test.tsx b/components/calendar/__tests__/task-plugin-surfaces.test.tsx
new file mode 100644
index 000000000..473f460e5
--- /dev/null
+++ b/components/calendar/__tests__/task-plugin-surfaces.test.tsx
@@ -0,0 +1,110 @@
+import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { TaskModal } from '../task-modal';
+import { TaskToolbar } from '../task-toolbar';
+import { filterTasksForList } from '../task-list-view';
+import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
+import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
+import type { Calendar, CalendarTask } from '@/lib/jmap/types';
+
+// Plugin surfaces for tasks: a section in a task's details and buttons in
+// the task list toolbar acting on the tasks the list shows.
+
+vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
+
+const calendars = [{ id: 'cal-1', name: 'Tasks', isShared: false }] as unknown as Calendar[];
+
+function makeTask(id: string, overrides: Partial = {}): CalendarTask {
+ return {
+ id,
+ '@type': 'Task',
+ uid: `uid-${id}`,
+ title: `Task ${id}`,
+ description: '',
+ due: null,
+ showWithoutTime: true,
+ priority: 0,
+ progress: 'needs-action',
+ calendarIds: { 'cal-1': true },
+ alerts: null,
+ ...overrides,
+ } as unknown as CalendarTask;
+}
+
+const dispatch = vi.fn().mockResolvedValue(undefined);
+
+beforeEach(() => {
+ dispatch.mockClear();
+ setActionDispatcher(dispatch);
+});
+
+afterEach(() => {
+ cleanup();
+ clearSlotOffers();
+ resetActions();
+ setActionDispatcher(null);
+});
+
+describe('task-detail-section', () => {
+ it('shows an open task to the plugin', () => {
+ offerSlots('p1', ['task-detail-section']);
+ render();
+
+ const frame = screen.getByTestId('plugin-frame-task-detail-section');
+ expect(framePropsOf(frame).task).toMatchObject({ id: 't1', uid: 'uid-t1', title: 'Task t1', priority: 5, calendarId: 'cal-1', isComplete: false });
+ });
+
+ it('is not shown for a new task or without a plugin', () => {
+ offerSlots('p1', ['task-detail-section']);
+ const { unmount } = render();
+ expect(screen.queryByTestId('plugin-frame-task-detail-section')).toBeNull();
+ unmount();
+
+ clearSlotOffers();
+ render();
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ });
+});
+
+describe('task-list-actions', () => {
+ const toolbar = {
+ filter: 'all' as const,
+ showCompleted: false,
+ onFilterChange: vi.fn(),
+ onShowCompletedChange: vi.fn(),
+ onCreateTask: vi.fn(),
+ };
+
+ it('puts plugin buttons after the create button and sends the listed task ids', async () => {
+ act(() => setPluginActions('pm', 'Planner', 'task-list-actions', [{ id: 'plan', label: 'Plan my day' }]));
+ render();
+
+ const button = screen.getByRole('button', { name: 'Plan my day' });
+ expect(screen.getByText('tasks.create').compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+
+ await act(async () => { fireEvent.click(button); });
+ expect(dispatch).toHaveBeenCalledWith('pm', 'task-list-actions', 'plan', { taskIds: ['t2', 't1'] });
+ });
+
+ it('draws icon-only buttons when compact', () => {
+ act(() => setPluginActions('pm', 'Planner', 'task-list-actions', [{ id: 'plan', label: 'Plan my day' }]));
+ render();
+ expect(screen.getByRole('button', { name: 'Plan my day' })).not.toHaveTextContent('Plan my day');
+ });
+
+ it('renders nothing without a plugin', () => {
+ render();
+ expect(document.querySelector('[data-plugin-actions]')).toBeNull();
+ });
+
+ it('lists the ids the task list shows, in its order', () => {
+ const tasks = [
+ makeTask('done', { progress: 'completed' }),
+ makeTask('later', { due: '2030-01-02' }),
+ makeTask('other-cal', { calendarIds: { 'cal-2': true } }),
+ makeTask('soon', { due: '2030-01-01' }),
+ ];
+ expect(filterTasksForList(tasks, ['cal-1'], 'all', false).map((t) => t.id)).toEqual(['soon', 'later']);
+ expect(filterTasksForList(tasks, ['cal-1'], 'all', true).map((t) => t.id)).toEqual(['soon', 'later', 'done']);
+ });
+});
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index df4e7f532..d3b17fe01 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -25,7 +25,7 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
import { CalendarDayView } from "@/components/calendar/calendar-day-view";
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
-import { TaskListView } from "@/components/calendar/task-list-view";
+import { TaskListView, filterTasksForList } from "@/components/calendar/task-list-view";
import { TaskToolbar } from "@/components/calendar/task-toolbar";
import { TaskModal } from "@/components/calendar/task-modal";
import { MiniCalendar } from "@/components/calendar/mini-calendar";
@@ -1488,6 +1488,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
return filtered;
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
+ // The tasks the task list shows; plugin buttons in its toolbar act on them.
+ const listedTaskIds = useMemo(
+ () => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted).map((task) => task.id),
+ [taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted],
+ );
+
// Tasks on the month, week and day grids follow the calendar filter like
// the events do (#1107).
const calendarTasks = useMemo(() => {
@@ -1679,6 +1685,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onFilterChange={taskStore.setFilter}
onShowCompletedChange={taskStore.setShowCompleted}
onCreateTask={openCreateTaskModal}
+ taskIds={listedTaskIds}
+ compact={isMobile}
/>
{format(day, "d")}
+
{hasAllDayArea && (
diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx
index 31ba69a59..1164876b5 100644
--- a/components/calendar/calendar-month-view.tsx
+++ b/components/calendar/calendar-month-view.tsx
@@ -18,6 +18,7 @@ import { toast } from "@/stores/toast-store";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { useScrollWindow } from "@/hooks/use-scroll-window";
import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
+import { PluginDayLabel } from "./plugin-day-label";
interface CalendarMonthViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -379,6 +380,7 @@ export function CalendarMonthView({
>
{formatDayNumber(day)}
+ {!isMobile && }
{isMobile && !showChips ? (
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx
index 2a342a034..d7f63c9d4 100644
--- a/components/calendar/calendar-week-view.tsx
+++ b/components/calendar/calendar-week-view.tsx
@@ -23,6 +23,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { PendingEventPreview } from "./event-modal";
import { readableTextOn } from "@/lib/color-transform";
+import { PluginDayLabel } from "./plugin-day-label";
interface CalendarWeekViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -436,6 +437,7 @@ export function CalendarWeekView({
>
{format(day, "d")}
+
);
})}
diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx
index e7c573d4c..7a54d8f66 100644
--- a/components/calendar/event-detail-popover.tsx
+++ b/components/calendar/event-detail-popover.tsx
@@ -25,6 +25,8 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
+import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { eventToView } from "@/lib/plugin-projection";
interface EventDetailPopoverProps {
event: CalendarEvent;
@@ -145,6 +147,7 @@ export function EventDetailPopover({
// Invitations (Teams above all) leave virtualLocations empty and bury the
// join URL in the description; surface it as the meeting link anyway.
const meeting = useMemo(() => findMeetingLink(event), [event]);
+ const pluginProps = useMemo(() => ({ event: eventToView(event) }), [event]);
const virtualLocation = meeting?.uri ?? null;
// A URL opens as a link, a location that only names the service joins the
@@ -580,6 +583,8 @@ export function EventDetailPopover({
)}
+
+
{/* Quick Note */}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 14b2d26aa..6a0b5b4f3 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -39,7 +39,7 @@ import {
calendarHooks,
type CalendarEventFormSavePatch,
} from "@/lib/plugin-hooks";
-import type { ConflictWarning } from "@/lib/plugin-types";
+import type { CalendarEventView, ConflictWarning } from "@/lib/plugin-types";
import { eventToView } from "@/lib/plugin-projection";
export interface PendingEventPreview {
@@ -446,6 +446,55 @@ export function EventModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
+ // calendar-event-detail: the event as plugins see it, in the read views.
+ const eventDetailProps = useMemo(() => (event ? { event: eventToView(event) } : null), [event]);
+
+ // calendar-event-form: the form's current state in the same shape. Typing
+ // would hand the plugin iframe new props on every key, so the view settles
+ // for half a second first.
+ const formEventView = useMemo(() => {
+ const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime || "00:00"}:00`;
+ const start = new Date(startStr);
+ let duration = event?.duration ?? "PT1H";
+ if (!isNaN(start.getTime())) {
+ if (allDay) {
+ const inclusiveEnd = new Date(`${endDate}T00:00:00`);
+ if (!isNaN(inclusiveEnd.getTime())) duration = buildAllDayDuration(start, inclusiveEnd < start ? start : inclusiveEnd);
+ } else {
+ const end = new Date(`${endDate}T${endTime || "00:00"}:00`);
+ if (!isNaN(end.getTime())) duration = buildDuration(start, end);
+ }
+ }
+ const base = event ? eventToView(event) : null;
+ return {
+ id: event?.id ?? "",
+ uid: event?.uid ?? "",
+ calendarId,
+ title,
+ description,
+ start: startStr,
+ duration,
+ timeZone: getEffectiveTimeZone(),
+ utcStart: null,
+ utcEnd: null,
+ isAllDay: allDay,
+ location,
+ status: base?.status ?? "confirmed",
+ participants: attendees.map((a) => {
+ const known = base?.participants.find((p) => p.email.toLowerCase() === a.email.toLowerCase());
+ return known ?? { name: a.name, email: a.email, role: "attendee", status: "needs-action" };
+ }),
+ isRecurring: recurrence !== "none",
+ keywords: base?.keywords ?? [],
+ };
+ }, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, location, attendees, recurrence]);
+ const [settledFormEvent, setSettledFormEvent] = useState(formEventView);
+ useEffect(() => {
+ const timer = setTimeout(() => setSettledFormEvent(formEventView), 500);
+ return () => clearTimeout(timer);
+ }, [formEventView]);
+ const eventFormProps = useMemo(() => ({ event: settledFormEvent }), [settledFormEvent]);
+
// Report live preview to parent for grid outline
useEffect(() => {
if (!onPreviewChange || isEdit) return;
@@ -823,6 +872,8 @@ export function EventModal({
)}
+
+ {eventDetailProps && }
@@ -1000,6 +1051,8 @@ export function EventModal({
)}
+
+ {eventDetailProps && }
@@ -1347,6 +1400,8 @@ export function EventModal({
)}
+
+
diff --git a/components/calendar/mini-calendar.tsx b/components/calendar/mini-calendar.tsx
index 03bc55dc8..cd7a9e8bc 100644
--- a/components/calendar/mini-calendar.tsx
+++ b/components/calendar/mini-calendar.tsx
@@ -12,6 +12,7 @@ import { getEventDayBounds } from "@/lib/calendar-utils";
import { displayNow } from "@/lib/timezone";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
+import { PluginDayLabel } from "./plugin-day-label";
type PickerView = "days" | "months" | "years";
@@ -199,6 +200,7 @@ export function MiniCalendar({
{hasEvent && !selected && !today && (
)}
+
);
diff --git a/components/calendar/plugin-day-label.tsx b/components/calendar/plugin-day-label.tsx
new file mode 100644
index 000000000..63ff9aa0f
--- /dev/null
+++ b/components/calendar/plugin-day-label.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import { cn } from "@/lib/utils";
+import { dayLabelsByDate, subscribeDayLabels, type RegisteredDayLabel } from "@/lib/plugin-sandbox/day-labels";
+
+const NONE: RegisteredDayLabel[] = [];
+const EMPTY_MAP = new Map();
+
+/** The labels plugins put on one day (api.ui.setDayLabels). */
+export function useDayLabels(date: string): RegisteredDayLabel[] {
+ const byDate = useSyncExternalStore(subscribeDayLabels, dayLabelsByDate, () => EMPTY_MAP);
+ return byDate.get(date) ?? NONE;
+}
+
+function tooltip(labels: RegisteredDayLabel[]): string {
+ return labels.map((l) => `${l.title ?? l.text ?? ""} (${l.pluginName})`.trim()).join("\n");
+}
+
+interface PluginDayLabelProps {
+ /**
+ * calendar-day-header: the label text under a day's name (day, week and
+ * month views). mini-calendar-marks: a dot on the day in the small month
+ * calendar; the parent must be positioned.
+ */
+ surface: "calendar-day-header" | "mini-calendar-marks";
+ /** YYYY-MM-DD */
+ date: string;
+ className?: string;
+}
+
+/** What plugins put on a calendar day. Renders nothing without labels. */
+export function PluginDayLabel({ surface, date, className }: PluginDayLabelProps) {
+ const labels = useDayLabels(date);
+
+ if (surface === "mini-calendar-marks") {
+ const marks = labels.filter((l) => l.dot);
+ if (marks.length === 0) return null;
+ return (
+ <>
+
+ {marks.map((l) => l.title ?? l.text ?? l.pluginName).join(", ")}
+ >
+ );
+ }
+
+ const withText = labels.filter((l) => l.text);
+ if (withText.length === 0) return null;
+ return (
+
+ {withText.map((l) => l.text).join(" ยท ")}
+
+ );
+}
diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx
index 8d896f181..6a2bf81b9 100644
--- a/components/calendar/task-list-view.tsx
+++ b/components/calendar/task-list-view.tsx
@@ -62,6 +62,60 @@ function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType {
+ const calIds = Object.keys(task.calendarIds);
+ return calIds.some(id => selectedCalendarIds.includes(id));
+ });
+
+ if (!showCompleted) {
+ result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled");
+ }
+
+ switch (filter) {
+ case "pending":
+ result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process");
+ break;
+ case "completed":
+ result = result.filter(task => task.progress === "completed");
+ break;
+ case "overdue":
+ result = result.filter(task => {
+ if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
+ return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due));
+ });
+ break;
+ }
+
+ // Sort: overdue first, then by due date (no due date last), then by priority
+ result.sort((a, b) => {
+ // Completed tasks at the bottom
+ if (a.progress === "completed" && b.progress !== "completed") return 1;
+ if (a.progress !== "completed" && b.progress === "completed") return -1;
+
+ // Tasks with due dates before those without
+ if (a.due && !b.due) return -1;
+ if (!a.due && b.due) return 1;
+ if (a.due && b.due) {
+ const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime();
+ if (dateCompare !== 0) return dateCompare;
+ }
+
+ // Higher priority first (lower number = higher priority, but 0 = no priority goes last)
+ const aPri = a.priority || 10;
+ const bPri = b.priority || 10;
+ return aPri - bPri;
+ });
+
+ return result;
+}
+
export function TaskListView({
tasks,
calendars,
@@ -77,53 +131,10 @@ export function TaskListView({
const timeFormat = useSettingsStore((s) => s.timeFormat);
const [quickAddTitle, setQuickAddTitle] = useState("");
- const filteredTasks = useMemo(() => {
- let result = tasks.filter(task => {
- const calIds = Object.keys(task.calendarIds);
- return calIds.some(id => selectedCalendarIds.includes(id));
- });
-
- if (!showCompleted) {
- result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled");
- }
-
- switch (filter) {
- case "pending":
- result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process");
- break;
- case "completed":
- result = result.filter(task => task.progress === "completed");
- break;
- case "overdue":
- result = result.filter(task => {
- if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
- return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due));
- });
- break;
- }
-
- // Sort: overdue first, then by due date (no due date last), then by priority
- result.sort((a, b) => {
- // Completed tasks at the bottom
- if (a.progress === "completed" && b.progress !== "completed") return 1;
- if (a.progress !== "completed" && b.progress === "completed") return -1;
-
- // Tasks with due dates before those without
- if (a.due && !b.due) return -1;
- if (!a.due && b.due) return 1;
- if (a.due && b.due) {
- const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime();
- if (dateCompare !== 0) return dateCompare;
- }
-
- // Higher priority first (lower number = higher priority, but 0 = no priority goes last)
- const aPri = a.priority || 10;
- const bPri = b.priority || 10;
- return aPri - bPri;
- });
-
- return result;
- }, [tasks, selectedCalendarIds, filter, showCompleted]);
+ const filteredTasks = useMemo(
+ () => filterTasksForList(tasks, selectedCalendarIds, filter, showCompleted),
+ [tasks, selectedCalendarIds, filter, showCompleted],
+ );
const handleToggle = useCallback((e: React.MouseEvent, task: CalendarTask) => {
e.stopPropagation();
diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx
index 8e648c490..ad2d32052 100644
--- a/components/calendar/task-modal.tsx
+++ b/components/calendar/task-modal.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useCallback, useRef } from "react";
+import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -8,6 +8,8 @@ import { X, Trash2, CalendarDays, Bell, Flag } from "@/components/icons";
import { format, parseISO } from "date-fns";
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
+import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { taskToView } from "@/lib/plugin-projection";
interface TaskModalProps {
task?: CalendarTask | null;
@@ -48,6 +50,8 @@ export function TaskModal({
const t = useTranslations("calendar");
const isEdit = !!task;
const titleRef = useRef(null);
+ // task-detail-section: the saved task, for an existing task only.
+ const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]);
const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn);
const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? "";
@@ -302,6 +306,8 @@ export function TaskModal({
+
+ {pluginTaskProps && }
{/* Footer */}
diff --git a/components/calendar/task-toolbar.tsx b/components/calendar/task-toolbar.tsx
index 0619d464f..7accbe05d 100644
--- a/components/calendar/task-toolbar.tsx
+++ b/components/calendar/task-toolbar.tsx
@@ -1,9 +1,11 @@
"use client";
+import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Plus } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
+import { PluginActions } from "@/components/plugins/plugin-actions";
import type { TaskViewFilter } from "@/stores/task-store";
interface TaskToolbarProps {
@@ -12,8 +14,14 @@ interface TaskToolbarProps {
onFilterChange: (filter: TaskViewFilter) => void;
onShowCompletedChange: (show: boolean) => void;
onCreateTask: () => void;
+ /** The tasks the list shows, in order: what plugin buttons here act on. */
+ taskIds?: string[];
+ /** Icon-only plugin buttons (phones). */
+ compact?: boolean;
}
+const NO_IDS: string[] = [];
+
const FILTERS: TaskViewFilter[] = ["all", "pending", "completed", "overdue"];
export function TaskToolbar({
@@ -22,8 +30,11 @@ export function TaskToolbar({
onFilterChange,
onShowCompletedChange,
onCreateTask,
+ taskIds = NO_IDS,
+ compact,
}: TaskToolbarProps) {
const t = useTranslations("calendar");
+ const pluginContext = useMemo(() => ({ taskIds }), [taskIds]);
return (
@@ -60,6 +71,8 @@ export function TaskToolbar({
{t("tasks.create")}
+
+
);
}
diff --git a/components/contacts/__tests__/contacts-plugin-surfaces.test.tsx b/components/contacts/__tests__/contacts-plugin-surfaces.test.tsx
new file mode 100644
index 000000000..2f48b430f
--- /dev/null
+++ b/components/contacts/__tests__/contacts-plugin-surfaces.test.tsx
@@ -0,0 +1,108 @@
+import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { ContactDetail } from '../contact-detail';
+import { ContactList } from '../contact-list';
+import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
+import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
+import type { ContactCard } from '@/lib/jmap/types';
+
+// Plugin surfaces in contacts: a section in a contact's details and buttons
+// in the list's selection toolbar.
+
+vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
+
+const alice: ContactCard = {
+ id: '1',
+ uid: 'uid-alice',
+ addressBookIds: { ab1: true },
+ name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
+ emails: { e0: { address: 'alice@example.com' } },
+ notes: { n0: { note: 'VIP customer' } },
+};
+const bob: ContactCard = {
+ id: '2',
+ addressBookIds: { ab1: true },
+ name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
+};
+
+const dispatch = vi.fn().mockResolvedValue(undefined);
+
+beforeEach(() => {
+ dispatch.mockClear();
+ setActionDispatcher(dispatch);
+});
+
+afterEach(() => {
+ cleanup();
+ clearSlotOffers();
+ resetActions();
+ setActionDispatcher(null);
+});
+
+describe('contact-detail-section', () => {
+ it('shows the contact to the plugin in its details', () => {
+ offerSlots('p1', ['contact-detail-section']);
+ render();
+
+ const frame = screen.getByTestId('plugin-frame-contact-detail-section');
+ expect(framePropsOf(frame).contact).toMatchObject({
+ id: '1', uid: 'uid-alice', addressBookId: 'ab1', firstName: 'Alice', lastName: 'Smith', emails: ['alice@example.com'],
+ });
+ // Below the contact's own fields.
+ expect(screen.getByText('VIP customer').compareDocumentPosition(frame) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ it('renders nothing without a plugin or without a contact', () => {
+ const { unmount } = render();
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ unmount();
+
+ offerSlots('p1', ['contact-detail-section']);
+ render();
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ });
+});
+
+describe('contact-list-actions', () => {
+ const props = {
+ contacts: [alice, bob],
+ selectedContactId: null,
+ searchQuery: '',
+ onSearchChange: vi.fn(),
+ onSelectContact: vi.fn(),
+ onCreateNew: vi.fn(),
+ onToggleSelection: vi.fn(),
+ onSelectRangeContacts: vi.fn(),
+ onSelectAll: vi.fn(),
+ onClearSelection: vi.fn(),
+ onBulkDelete: vi.fn(),
+ onBulkAddToGroup: vi.fn(),
+ onBulkExport: vi.fn(),
+ onEditContact: vi.fn(),
+ onDeleteContact: vi.fn(),
+ onAddContactToGroup: vi.fn(),
+ };
+
+ it('puts plugin buttons in the selection toolbar and sends the selected ids', async () => {
+ act(() => setPluginActions('crm', 'CRM', 'contact-list-actions', [{ id: 'sync', label: 'Sync to CRM' }]));
+ render();
+
+ const button = screen.getByRole('button', { name: 'Sync to CRM' });
+ // Next to the app's own bulk buttons.
+ expect(button.closest('div.flex-wrap')?.parentElement).toContainElement(screen.getByText('bulk.delete'));
+
+ await act(async () => { fireEvent.click(button); });
+ expect(dispatch).toHaveBeenCalledWith('crm', 'contact-list-actions', 'sync', { contactIds: ['1', '2'] });
+ });
+
+ it('shows no plugin buttons without a selection or without a plugin', () => {
+ act(() => setPluginActions('crm', 'CRM', 'contact-list-actions', [{ id: 'sync', label: 'Sync to CRM' }]));
+ const { unmount } = render();
+ expect(screen.queryByRole('button', { name: 'Sync to CRM' })).toBeNull();
+ unmount();
+
+ resetActions();
+ render();
+ expect(document.querySelector('[data-plugin-actions]')).toBeNull();
+ });
+});
diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx
index 4f048fc72..14c66479c 100644
--- a/components/contacts/contact-detail.tsx
+++ b/components/contacts/contact-detail.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "@/components/icons";
import { Avatar } from "@/components/ui/avatar";
@@ -125,6 +125,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0;
+ const pluginContactProps = useMemo(() => (contact ? { contact: contactToView(contact) } : null), [contact]);
// Tell plugins once per contact shown here, not on every edit of that card.
const latestContact = useRef(contact);
@@ -509,6 +510,10 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
)}
+ {pluginContactProps && (
+
+ )}
+
{(contact.created || contact.updated) && (
diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx
index 2875279b1..8c0bb12bc 100644
--- a/components/contacts/contact-list.tsx
+++ b/components/contacts/contact-list.tsx
@@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu";
+import { PluginActions } from "@/components/plugins/plugin-actions";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
@@ -268,6 +269,7 @@ export function ContactList({
}, [sorted, locale, sortByLastName]);
const hasSelection = selectedContactIds.size > 0;
+ const pluginContext = useMemo(() => ({ contactIds: [...selectedContactIds] }), [selectedContactIds]);
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
return (
@@ -521,6 +523,7 @@ export function ContactList({
{t("bulk.delete")}
+
diff --git a/components/files/__tests__/files-plugin-surfaces.test.tsx b/components/files/__tests__/files-plugin-surfaces.test.tsx
new file mode 100644
index 000000000..c2bcee520
--- /dev/null
+++ b/components/files/__tests__/files-plugin-surfaces.test.tsx
@@ -0,0 +1,247 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { FileBrowser } from '../file-browser';
+import { FileDetailsPanel } from '../file-details-panel';
+import { FilePreviewModal } from '../file-preview-modal';
+import { ImagePreviewModal } from '../image-preview-modal';
+import { useFileStore, type FileResource } from '@/stores/file-store';
+import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
+import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { FileResourceView } from '@/lib/plugin-types';
+
+// Plugin surfaces in Files: entries in an item's menu and buttons in the
+// selection toolbar (file-actions), and a section in the previews and the
+// details panel (file-preview-section).
+
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null }));
+vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
+
+const folder = {
+ id: 'f-docs', name: 'Documents', serverName: 'Documents', isDirectory: true, contentType: '', contentLength: 0,
+ lastModified: '2026-10-01T00:00:00Z', blobId: null, parentId: null,
+} as FileResource;
+const notes = {
+ id: 'f-notes', name: 'notes.txt', serverName: 'notes.txt', isDirectory: false, contentType: 'text/plain', contentLength: 10,
+ lastModified: '2026-10-01T00:00:00Z', blobId: 'b-notes', parentId: null,
+} as FileResource;
+const photo = {
+ id: 'f-photo', name: 'photo.png', serverName: 'photo.png', isDirectory: false, contentType: 'image/png', contentLength: 99,
+ lastModified: '2026-10-02T00:00:00Z', blobId: 'b-photo', parentId: null,
+} as FileResource;
+
+const notesView: FileResourceView = {
+ id: 'f-notes', name: 'notes.txt', type: 'file', size: 10, mimeType: 'text/plain', path: '/', modified: '2026-10-01T00:00:00Z',
+};
+
+function renderBrowser(selected: string[] = []) {
+ const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient;
+ act(() => {
+ useFileStore.setState({
+ client, sharedRoots: [], allFolders: [folder], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true,
+ });
+ });
+ const noop = vi.fn(async () => {});
+ render(
+ ''}
+ favorites={[]}
+ recentFiles={[]}
+ onToggleFavorite={vi.fn()}
+ onOpenRecent={vi.fn()}
+ onRenameNode={noop}
+ onDeleteNode={noop}
+ onDownloadNode={noop}
+ onRevealNode={noop}
+ onRemoveRecent={vi.fn()}
+ client={client}
+ ownAccountId="a"
+ />,
+ );
+}
+
+function rowOf(name: string): HTMLElement {
+ return within(screen.getByRole('grid', { name: 'file_list' })).getByText(name).closest('[role=row]') as HTMLElement;
+}
+
+const dispatch = vi.fn().mockResolvedValue(undefined);
+
+beforeEach(() => {
+ localStorage.clear();
+ dispatch.mockClear();
+ setActionDispatcher(dispatch);
+});
+
+afterEach(() => {
+ cleanup();
+ clearSlotOffers();
+ resetActions();
+ setActionDispatcher(null);
+ act(() => { useFileStore.setState({ client: null, sharedRoots: [], allFolders: [] }); });
+});
+
+describe('file-actions', () => {
+ it('adds entries to an item menu, filtered by its type, and sends the item', async () => {
+ act(() => setPluginActions('ocr', 'OCR', 'file-actions', [
+ { id: 'read-text', label: 'Read text', mimeTypes: ['text/'] },
+ { id: 'edit-image', label: 'Edit image', mimeTypes: ['image/'] },
+ { id: 'archive', label: 'Archive' },
+ ]));
+ renderBrowser();
+
+ fireEvent.contextMenu(rowOf('notes.txt'));
+ expect(await screen.findByRole('menuitem', { name: 'Read text' })).toBeInTheDocument();
+ expect(screen.queryByRole('menuitem', { name: 'Edit image' })).toBeNull();
+ // After the app's own entries.
+ const items = screen.getAllByRole('menuitem').map((el) => el.textContent);
+ expect(items.indexOf('Archive')).toBeGreaterThan(items.findIndex((text) => text?.includes('delete')));
+
+ await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Read text' })); });
+ expect(dispatch).toHaveBeenCalledWith('ocr', 'file-actions', 'read-text', { files: [notesView] });
+ });
+
+ it('keeps type-specific entries off folders', async () => {
+ act(() => setPluginActions('ocr', 'OCR', 'file-actions', [
+ { id: 'read-text', label: 'Read text', mimeTypes: ['text/'] },
+ { id: 'archive', label: 'Archive' },
+ ]));
+ renderBrowser();
+
+ fireEvent.contextMenu(rowOf('Documents'));
+ expect(await screen.findByRole('menuitem', { name: 'Archive' })).toBeInTheDocument();
+ expect(screen.queryByRole('menuitem', { name: 'Read text' })).toBeNull();
+ });
+
+ it('adds buttons to the selection toolbar with the selected items', async () => {
+ act(() => setPluginActions('ocr', 'OCR', 'file-actions', [
+ { id: 'archive', label: 'Archive' },
+ { id: 'read-text', label: 'Read text', mimeTypes: ['text/'] },
+ ]));
+ renderBrowser(['notes.txt', 'photo.png']);
+
+ const toolbar = screen.getByRole('toolbar', { name: 'selection_actions' });
+ const button = within(toolbar).getByRole('button', { name: 'Archive' });
+ // Several items: no type filter applies, so every entry shows.
+ expect(within(toolbar).getByRole('button', { name: 'Read text' })).toBeInTheDocument();
+
+ await act(async () => { fireEvent.click(button); });
+ const context = dispatch.mock.calls[0][3] as { files: FileResourceView[] };
+ expect(context.files.map((f) => f.id)).toEqual(['f-notes', 'f-photo']);
+ });
+
+ it('filters the toolbar by the type of a single selected file', () => {
+ act(() => setPluginActions('ocr', 'OCR', 'file-actions', [{ id: 'read-text', label: 'Read text', mimeTypes: ['text/'] }]));
+ renderBrowser(['photo.png']);
+
+ const toolbar = screen.getByRole('toolbar', { name: 'selection_actions' });
+ expect(within(toolbar).queryByRole('button', { name: 'Read text' })).toBeNull();
+ });
+
+ it('adds nothing without a plugin', async () => {
+ renderBrowser(['notes.txt']);
+ expect(document.querySelector('[data-plugin-actions]')).toBeNull();
+ fireEvent.contextMenu(rowOf('notes.txt'));
+ await screen.findAllByRole('menuitem');
+ expect(screen.queryByRole('menuitem', { name: 'Archive' })).toBeNull();
+ });
+});
+
+describe('file-preview-section', () => {
+ const panelProps = {
+ resource: notes,
+ selection: [notes],
+ folderName: 'Files',
+ folderItems: [notes],
+ path: '/notes.txt',
+ isStarred: false,
+ canShare: false,
+ canPreview: true,
+ canEdit: false,
+ iconOptions: { showIcons: true, coloredIcons: true, showThumbnails: false },
+ getImageUrl: async () => '',
+ onClose: vi.fn(),
+ onOpen: vi.fn(),
+ onEdit: vi.fn(),
+ onDownload: vi.fn(),
+ onShare: vi.fn(),
+ onToggleStar: vi.fn(),
+ } as unknown as React.ComponentProps;
+
+ it('shows the selected file in the details panel', () => {
+ offerSlots('p1', ['file-preview-section']);
+ render();
+ expect(framePropsOf(screen.getByTestId('plugin-frame-file-preview-section')).file).toEqual(notesView);
+ });
+
+ it('passes the selection from the browser to the details panel', () => {
+ offerSlots('p1', ['file-preview-section']);
+ localStorage.setItem('files-show-details', '1');
+ renderBrowser(['notes.txt']);
+ expect(framePropsOf(screen.getByTestId('plugin-frame-file-preview-section')).file).toEqual(notesView);
+ });
+
+ it('shows the previewed file under the text preview', async () => {
+ offerSlots('p1', ['file-preview-section']);
+ render(
+ ({ blob: new Blob(['hi'], { type: 'text/plain' }), contentType: 'text/plain' })}
+ />,
+ );
+ expect(framePropsOf(await screen.findByTestId('plugin-frame-file-preview-section')).file).toEqual(notesView);
+ });
+
+ it('keeps clicks on the section from closing the image preview', () => {
+ offerSlots('p1', ['file-preview-section']);
+ const onClose = vi.fn();
+ render( {}} getImageUrl={async () => ''} />);
+
+ const frame = screen.getByTestId('plugin-frame-file-preview-section');
+ expect(framePropsOf(frame).file).toMatchObject({ id: 'f-photo' });
+ fireEvent.click(frame);
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it('renders nothing without a plugin or a file', () => {
+ render();
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ cleanup();
+
+ offerSlots('p1', ['file-preview-section']);
+ render( {}} getImageUrl={async () => ''} />);
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ });
+});
diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx
index f245ed5e2..c616e391e 100644
--- a/components/files/file-browser.tsx
+++ b/components/files/file-browser.tsx
@@ -26,11 +26,13 @@ import { FileDetailsPanel } from "@/components/files/file-details-panel";
import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
-import { useFileStore, type FileResource } from "@/stores/file-store";
+import { resourceToView, useFileStore, type FileResource } from "@/stores/file-store";
+import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
import { toast } from "@/stores/toast-store";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNodeRights } from "@/lib/jmap/types";
+import type { FileResourceView } from "@/lib/plugin-types";
interface ClipboardState {
mode: "cut" | "copy";
@@ -371,6 +373,8 @@ export function FileBrowser({
const cutNames = useMemo(() => new Set(clipboard?.mode === "cut" ? clipboard.names : []), [clipboard]);
const selectedList = useMemo(() => displayResources.filter((r) => selectedResources.has(r.name)), [displayResources, selectedResources]);
const singleSelected = selectedList.length === 1 ? selectedList[0] : null;
+ // What plugins see of the selection (file-actions, file-preview-section).
+ const selectedViews = useMemo(() => selectedList.map((r) => resourceToView(r, currentPath)), [selectedList, currentPath]);
// ---- Breadcrumbs -----------------------------------------------------------
const crumbs: Crumb[] = useMemo(() => {
@@ -759,6 +763,7 @@ export function FileBrowser({
{ closeMenu(); onCopy(names); }} />
{ closeMenu(); deleteNames(names); }} />
+ resourceToView(r, currentPath))} onRan={closeMenu} />
>
);
}
@@ -800,6 +805,11 @@ export function FileBrowser({
{ closeMenu(); toggleDetails(true); }} />
{ closeMenu(); deleteNames([resource.name]); }} />
+
>
);
};
@@ -962,6 +972,7 @@ export function FileBrowser({
canShare={canShare(singleSelected)}
canPreview={!!singleSelected && isPreviewable(singleSelected.name)}
canEdit={!!singleSelected && !!onEditFile && !!isOfficeEditable?.(singleSelected.name)}
+ pluginFile={singleSelected ? selectedViews[0] : null}
iconOptions={iconOptions}
getImageUrl={getImageUrl}
onClose={() => toggleDetails(false)}
@@ -991,6 +1002,8 @@ export function FileBrowser({
onCopy: () => onCopy(selectedList.map((r) => r.name)),
onDelete: () => deleteNames(selectedList.map((r) => r.name)),
onClear: onClearSelection,
+ pluginFiles: selectedViews,
+ pluginMimeType: singleSelected ? (singleSelected.isDirectory ? "" : singleSelected.contentType) : undefined,
} : null;
return (
@@ -1272,3 +1285,15 @@ function ListSkeleton() {
);
}
+
+/** Plugin entries at the end of a file's menu, after a separator when there are any. */
+function FilePluginMenuEntries({ files, mimeType, onRan }: { files: FileResourceView[]; mimeType?: string; onRan: () => void }) {
+ const hasActions = usePluginActions("file-actions", { mimeType }).length > 0;
+ if (!hasActions) return null;
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/components/files/file-details-panel.tsx b/components/files/file-details-panel.tsx
index 70c35f0b0..5e741b53d 100644
--- a/components/files/file-details-panel.tsx
+++ b/components/files/file-details-panel.tsx
@@ -1,10 +1,13 @@
"use client";
+import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { X, Download, Share2, Star, StarOff, FolderOpen, Eye, SquarePen, Files } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { cn, formatFileSize } from "@/lib/utils";
import type { FileResource } from "@/stores/file-store";
+import type { FileResourceView } from "@/lib/plugin-types";
+import { PluginSlot } from "@/components/plugins/plugin-slot";
import {
ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions,
} from "@/components/files/file-item-parts";
@@ -31,6 +34,8 @@ interface FileDetailsPanelProps {
onShare: () => void;
onToggleStar: () => void;
className?: string;
+ /** The one selected item as plugins see it (file-preview-section). */
+ pluginFile?: FileResourceView | null;
}
function kindOf(resource: FileResource, t: (key: string, values?: Record) => string): string {
@@ -46,9 +51,10 @@ function totalSize(items: FileResource[]): number {
export function FileDetailsPanel({
resource, selection, folderName, folderItems, path, isStarred, canShare, canPreview, canEdit,
- iconOptions, getImageUrl, onClose, onOpen, onEdit, onDownload, onShare, onToggleStar, className,
+ iconOptions, getImageUrl, onClose, onOpen, onEdit, onDownload, onShare, onToggleStar, className, pluginFile,
}: FileDetailsPanelProps) {
const t = useTranslations("files");
+ const pluginProps = useMemo(() => (pluginFile ? { file: pluginFile } : null), [pluginFile]);
const header = (
@@ -155,6 +161,7 @@ export function FileDetailsPanel({
|
+ {pluginProps &&
}
,
);
}
diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx
index 212d63a10..c83e2a471 100644
--- a/components/files/file-preview-modal.tsx
+++ b/components/files/file-preview-modal.tsx
@@ -8,6 +8,8 @@ import { ZoomableImage } from "@/components/files/zoomable-image";
import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview";
import dynamic from "next/dynamic";
import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview";
+import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
+import type { FileResourceView } from "@/lib/plugin-types";
// pdf.js-based inline viewer for mobile (no native inline PDF viewer). Loaded
// only on the mobile PDF path so pdfjs-dist + its worker never reach the
@@ -22,6 +24,8 @@ interface FilePreviewModalProps {
onClose: () => void;
onDownload: () => Promise | void;
getFileContent: () => Promise<{ blob: Blob; contentType: string }>;
+ /** The previewed file as plugins see it (file-preview-section). */
+ file?: FileResourceView | null;
}
function SimpleMarkdown({ content }: { content: string }) {
@@ -115,7 +119,7 @@ function processInline(text: string): React.ReactNode {
return parts.length === 1 ? parts[0] : <>{parts}>;
}
-export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: FilePreviewModalProps) {
+export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file }: FilePreviewModalProps) {
const t = useTranslations("files");
const [content, setContent] = useState(null);
const [objectUrl, setObjectUrl] = useState(null);
@@ -381,6 +385,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
/>
)}
+
);
}
diff --git a/components/files/file-preview-plugin-section.tsx b/components/files/file-preview-plugin-section.tsx
new file mode 100644
index 000000000..ff65b66c3
--- /dev/null
+++ b/components/files/file-preview-plugin-section.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { useMemo } from "react";
+import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
+import type { FileResourceView } from "@/lib/plugin-types";
+
+/**
+ * file-preview-section in the full-screen previews: a panel under the
+ * preview. Nothing at all when no plugin offers it or the file is unknown.
+ */
+export function FilePreviewPluginSection({ file }: { file?: FileResourceView | null }) {
+ const offered = usePluginSlotOffers("file-preview-section").length > 0;
+ const extraProps = useMemo(() => (file ? { file } : null), [file]);
+ if (!offered || !extraProps) return null;
+ return (
+ // The overlay closes on a click; the panel is not part of the backdrop.
+ e.stopPropagation()}
+ >
+
+
+ );
+}
diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx
index 8133f2d1d..424d0dc72 100644
--- a/components/files/files-app.tsx
+++ b/components/files/files-app.tsx
@@ -1,13 +1,13 @@
"use client";
-import { useState, useEffect, useRef, useCallback } from "react";
+import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
-import { useFileStore, resourceServerRef, type UploadResult } from "@/stores/file-store";
+import { useFileStore, resourceServerRef, resourceToView, type UploadResult } from "@/stores/file-store";
import { toast } from "@/stores/toast-store";
import { cn, formatFileSize } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
@@ -544,6 +544,13 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
const proInterfaceActive = useProInterfaceActive();
const isFocusedProTab = useIsFocusedProTab();
const filesLinkPath = appPath(buildFilesPath(currentPath, previewFile));
+
+ // The previewed file as plugins see it (file-preview-section).
+ const previewedName = previewFile ?? previewImage;
+ const previewedView = useMemo(() => {
+ const resource = previewedName ? resources.find((r) => r.name === previewedName) : undefined;
+ return resource ? resourceToView(resource, currentPath) : null;
+ }, [previewedName, resources, currentPath]);
useDeepLinkUrl(
isEmbedded
? (isFocusedProTab ? filesLinkPath : null)
@@ -715,6 +722,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
onClose={() => setPreviewImage(null)}
onDownload={handleDownload}
getImageUrl={getImageUrl}
+ file={previewedView}
/>
)}
@@ -725,6 +733,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
onClose={() => setPreviewFile(null)}
onDownload={() => handleDownload(previewFile)}
getFileContent={() => getFileContent(previewFile)}
+ file={previewedView}
/>
)}
diff --git a/components/files/files-header.tsx b/components/files/files-header.tsx
index c9989adc3..b39ccfd94 100644
--- a/components/files/files-header.tsx
+++ b/components/files/files-header.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import {
Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList,
@@ -12,6 +12,8 @@ import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
+import { PluginActions } from "@/components/plugins/plugin-actions";
+import type { FileResourceView } from "@/lib/plugin-types";
export type SortKey = "name" | "size" | "modified";
export type SortDir = "asc" | "desc";
@@ -39,6 +41,10 @@ interface SelectionActions {
onCopy: () => void;
onDelete: () => void;
onClear: () => void;
+ /** The selection as plugins see it (file-actions). */
+ pluginFiles: FileResourceView[];
+ /** The one selected item's type, so plugins can offer type-specific actions; undefined for several. */
+ pluginMimeType?: string;
}
interface FilesHeaderProps {
@@ -132,6 +138,7 @@ export function FilesHeader({
+
@@ -426,3 +433,9 @@ function SortItem({ label, checked, onClick, icon: Icon }: {
);
}
+
+/** Plugin buttons in the selection toolbar, icon-only like the app's own. */
+function SelectionPluginActions({ files, mimeType }: { files: FileResourceView[]; mimeType?: string }) {
+ const context = useMemo(() => ({ files }), [files]);
+ return ;
+}
diff --git a/components/files/image-preview-modal.tsx b/components/files/image-preview-modal.tsx
index fff8793af..129998a60 100644
--- a/components/files/image-preview-modal.tsx
+++ b/components/files/image-preview-modal.tsx
@@ -5,15 +5,19 @@ import { X, Download } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useTranslations } from "next-intl";
import { ZoomableImage } from "@/components/files/zoomable-image";
+import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
+import type { FileResourceView } from "@/lib/plugin-types";
interface ImagePreviewModalProps {
name: string;
onClose: () => void;
onDownload: (name: string) => Promise;
getImageUrl: (name: string) => Promise;
+ /** The previewed file as plugins see it (file-preview-section). */
+ file?: FileResourceView | null;
}
-export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: ImagePreviewModalProps) {
+export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file }: ImagePreviewModalProps) {
const t = useTranslations("files");
const [imageUrl, setImageUrl] = useState(null);
const [loading, setLoading] = useState(true);
@@ -111,6 +115,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: Im
)}
+
);
}
diff --git a/components/plugins/__tests__/fake-slot-offer.tsx b/components/plugins/__tests__/fake-slot-offer.tsx
new file mode 100644
index 000000000..2f4c68e33
--- /dev/null
+++ b/components/plugins/__tests__/fake-slot-offer.tsx
@@ -0,0 +1,37 @@
+// Test helpers for mount-site tests: a plugin that offers a slot, and a
+// stand-in for the sandboxed iframe that shows which props it was handed.
+//
+// Use with:
+// vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
+import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry';
+import type { SlotName } from '@/lib/plugin-types';
+
+const registered = new Set();
+
+/** Registers an active plugin that offers these slots. */
+export function offerSlots(pluginId: string, slots: SlotName[]): void {
+ register({
+ plugin: { id: pluginId, name: pluginId },
+ code: '',
+ tier: 'untrusted',
+ background: {},
+ slotOffers: slots.map((name) => ({ name, order: 100, hasShouldShow: false })),
+ hookDisposables: [],
+ } as unknown as ActivePlugin);
+ registered.add(pluginId);
+}
+
+export function clearSlotOffers(): void {
+ for (const id of registered) deregister(id);
+ registered.clear();
+}
+
+/** Drop-in for PluginIframeSlot: no iframe, the props as JSON. */
+export function PluginIframeSlot({ pluginId, slot, extraProps }: { pluginId: string; slot: string; extraProps?: Record }) {
+ return ;
+}
+
+/** The props the fake iframe for this slot received. */
+export function framePropsOf(el: HTMLElement): Record {
+ return JSON.parse(el.getAttribute('data-props') || '{}') as Record;
+}
diff --git a/lib/__tests__/plugin-slot-catalog.test.ts b/lib/__tests__/plugin-slot-catalog.test.ts
index 75f76d686..5b5581904 100644
--- a/lib/__tests__/plugin-slot-catalog.test.ts
+++ b/lib/__tests__/plugin-slot-catalog.test.ts
@@ -34,7 +34,7 @@ function isMounted(name: string): boolean {
// that finishes an area removes it here; the test then insists every
// surface in it has a mount site.
const NOT_YET_MOUNTED = new Set([
- 'app', 'navigation', 'mail-list', 'reader', 'composer', 'calendar', 'contacts', 'tasks', 'files', 'settings',
+ 'app', 'navigation', 'mail-list', 'reader', 'composer', 'settings',
]);
describe('slot catalogue', () => {
diff --git a/stores/file-store.ts b/stores/file-store.ts
index 952565f92..6d21d40d6 100644
--- a/stores/file-store.ts
+++ b/stores/file-store.ts
@@ -293,7 +293,7 @@ function joinPath(base: string, name: string): string {
}
/** A listed item as plugins see it; `path` is the folder that holds it. */
-function resourceToView(r: FileResource, path: string): FileResourceView {
+export function resourceToView(r: FileResource, path: string): FileResourceView {
return fileToView({
id: r.id,
parentId: r.parentId,