Files
webmail/stores/task-store.ts
T
Jacob Zeitz e2fc2c3c90 feat(plugins): tasks carry alerts
api.tasks.create and update take alerts by id: a trigger from the task's
due (or start) as an ISO 8601 duration, or at an instant in UTC; display
or email; at most five. An update merges by id, each alert written by its
JMAP patch path: an alert given is set, one given null is removed, and
alerts the plugin did not name, such as one another app wrote, stay as
the server holds them; null for the whole map removes every alert.
Whether the map is patched or written whole is decided on the task as the
server holds it, not an older copy. An alert kept with the same trigger
and action stays acknowledged, so a snooze is an update of its trigger,
which then fires again. While a task uses its calendar's default alerts,
a change makes them the task's own under new ids, as the task form does,
an alert under a default's id is refused rather than dropped, and
removing every alert turns the defaults off too.

TaskView returns the alerts, each with when it was acknowledged, and
useDefaultAlerts. api.tasks.acknowledgeAlert dismisses one alert (or
undoes that with null) by writing only its acknowledged. A plugin's
writes to one task go one after another, since Stalwart refuses a set
while another is under way on the same object. The task store applies
patch paths to the task the caller read, so a task the list does not
hold is shown and reported whole.
2026-10-31 19:52:02 +01:00

162 lines
6.9 KiB
TypeScript

import { create } from 'zustand';
import type { CalendarTask } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { debug } from '@/lib/debug';
import { taskHooks } from '@/lib/plugin-hooks';
import { taskToView } from '@/lib/plugin-projection';
import { applyPatchObject, patchedProperties } from '@/lib/jmap/patch-object';
export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue';
/**
* The `error` a failed task load leaves. The tasks loaded before stay; the
* task list shows a notice for it.
*/
export const TASKS_LOAD_ERROR = 'Failed to fetch tasks';
interface TaskStore {
tasks: CalendarTask[];
selectedTaskId: string | null;
filter: TaskViewFilter;
showCompleted: boolean;
isLoading: boolean;
error: string | null;
setTasks: (tasks: CalendarTask[]) => void;
setSelectedTaskId: (id: string | null) => void;
setFilter: (filter: TaskViewFilter) => void;
setShowCompleted: (show: boolean) => void;
fetchTasks: (client: IJMAPClient, calendarIds?: string[]) => Promise<void>;
/** Resolves to null when a plugin stopped the create. */
createTask: (client: IJMAPClient, task: Partial<CalendarTask>) => Promise<CalendarTask | null>;
/** false when a plugin stopped it (the plugin tells the user why). */
/**
* `updates` may name members by path (`alerts/<id>/acknowledged`). `base`
* is the task as the caller read it, for a task the list does not hold
* or holds in an older state.
*/
updateTask: (client: IJMAPClient, id: string, updates: Partial<CalendarTask>, base?: CalendarTask) => Promise<boolean>;
deleteTask: (client: IJMAPClient, id: string) => Promise<boolean>;
toggleTaskComplete: (client: IJMAPClient, task: CalendarTask) => Promise<void>;
clearTasks: () => void;
}
// Each fetch gets a number: only the latest one's answer is kept, so an
// answer that comes late never puts an older list back.
let latestFetch = 0;
export const useTaskStore = create<TaskStore>((set, get) => ({
tasks: [],
selectedTaskId: null,
filter: 'all',
showCompleted: false,
isLoading: false,
error: null,
setTasks: (tasks) => set({ tasks }),
setSelectedTaskId: (id) => set({ selectedTaskId: id }),
setFilter: (filter) => {
if (get().filter === filter) return;
set({ filter });
void taskHooks.onTaskFilterChange.emit({ filter, showCompleted: get().showCompleted });
},
setShowCompleted: (show) => {
if (get().showCompleted === show) return;
set({ showCompleted: show });
void taskHooks.onTaskFilterChange.emit({ filter: get().filter, showCompleted: show });
},
fetchTasks: async (client, calendarIds) => {
debug.log('tasks', 'TaskStore/fetchTasks start', { calendarIds: calendarIds || 'all' });
const fetch = ++latestFetch;
const stillCurrent = () => fetch === latestFetch;
set({ isLoading: true, error: null });
try {
const tasks = await client.getCalendarTasks(calendarIds);
// A fetch started later decides the list.
if (!stillCurrent()) return;
debug.log('tasks', 'TaskStore/fetchTasks received', tasks.length, 'tasks');
tasks.forEach((t, i) => {
debug.log('tasks', `TaskStore/fetchTasks [${i}]`, {
id: t.id, uid: t.uid, '@type': t['@type'],
title: t.title, due: t.due, progress: t.progress,
showWithoutTime: t.showWithoutTime, calendarIds: t.calendarIds,
});
});
set({ tasks, isLoading: false });
if (taskHooks.onTasksLoaded.size > 0) void taskHooks.onTasksLoaded.emit(tasks.map(taskToView));
} catch (error) {
debug.error('TaskStore/fetchTasks failed', error);
if (stillCurrent()) set({ isLoading: false, error: TASKS_LOAD_ERROR });
}
},
createTask: async (client, task) => {
debug.log('tasks', 'TaskStore/createTask', task);
if (!(await taskHooks.onBeforeTaskCreate.intercept(taskToView({ id: '', ...task } as CalendarTask)))) return null;
const created = await client.createCalendarTask(task);
debug.log('tasks', 'TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title });
set({ tasks: [...get().tasks, created] });
void taskHooks.onTaskCreate.emit(taskToView(created));
return created;
},
updateTask: async (client, id, updates, base) => {
const before = base ?? get().tasks.find(t => t.id === id);
// `updates` may name members by path (`alerts/<id>/acknowledged`);
// plugins read the patch as whole properties.
const patch = patchedProperties(before ?? { id }, updates);
if (!(await taskHooks.onBeforeTaskUpdate.intercept({ taskId: id, patch }))) return false;
const newId = await client.updateCalendarTask(id, updates);
if (newId && newId !== id) {
// Moved into a calendar of another account: the server recreated it
// under a new id, so reload rather than patch the old entry. (#1204)
set({ selectedTaskId: get().selectedTaskId === id ? newId : get().selectedTaskId });
await get().fetchTasks(client);
const moved = get().tasks.find(t => t.id === newId)
?? ({ ...applyPatchObject(before ?? {}, updates), id: newId } as CalendarTask);
void taskHooks.onTaskUpdate.emit(taskToView(moved));
return true;
}
// Applied to the task the caller read, else as the store holds it now:
// another update may have finished meanwhile (two alerts acknowledged
// at once).
const current = base ?? get().tasks.find(t => t.id === id) ?? { id };
const updated = { ...applyPatchObject(current, updates), updated: new Date().toISOString() } as CalendarTask;
set({
tasks: get().tasks.map(t => t.id === id ? updated : t),
});
void taskHooks.onTaskUpdate.emit(taskToView(updated));
return true;
},
deleteTask: async (client, id) => {
const title = get().tasks.find(t => t.id === id)?.title || '';
if (!(await taskHooks.onBeforeTaskDelete.intercept({ taskId: id, title }))) return false;
await client.deleteCalendarTask(id);
set({
tasks: get().tasks.filter(t => t.id !== id),
selectedTaskId: get().selectedTaskId === id ? null : get().selectedTaskId,
});
void taskHooks.onTaskDelete.emit({ taskId: id, title });
return true;
},
toggleTaskComplete: async (client, task) => {
const newProgress = task.progress === 'completed' ? 'needs-action' : 'completed';
const updates: Partial<CalendarTask> = {
progress: newProgress,
};
await client.updateCalendarTask(task.id, updates);
set({
tasks: get().tasks.map(t => t.id === task.id ? { ...t, ...updates, updated: new Date().toISOString() } : t),
});
// A toggle is reported as onTaskToggleComplete only, not also as
// onTaskUpdate, so a plugin that listens to both sees each change once.
void taskHooks.onTaskToggleComplete.emit({ taskId: task.id, complete: newProgress === 'completed' });
},
clearTasks: () => {
latestFetch += 1;
set({ tasks: [], selectedTaskId: null, error: null, isLoading: false });
},
}));