Files
native/src/widgets/build.ts
T
Linus Rath ae3121a746 fix: load calendar and tasks into the widgets when the app is not running #52
The calendar, tasks, files and scheduled-send parts of the widgets only
refreshed while the app's UI was running, because they reuse the app's
stores, which are bound to its JMAP client. A widget event or push that
woke the app without its UI refreshed mail only, so invitations
answered elsewhere, tasks ticked on another device and new events
stayed as the app last left them. A tick or RSVP the server refused in
that state was also never undone, since nothing reloaded those parts.

When no UI has started in the runtime, nothing else uses that client,
so the widget refresh now signs it in to the active account and loads
calendar, tasks, files and scheduled sends the way the app does. Once
the UI starts it owns the client again.
2026-10-02 12:01:20 +02:00

502 lines
20 KiB
TypeScript

// Builds the widget snapshot. Mail comes from JMAP on a client of the widgets'
// own (./jmap.ts). Calendar, tasks, birthdays, files and scheduled sends reuse
// the app's helpers, which are bound to the `jmapClient` singleton: they run
// when that singleton is connected to the active account, either because the
// app is running or because no UI has started in this runtime and the refresh
// signed it in itself (see borrowSingleton). Otherwise (the app is up but
// signed in elsewhere, or offline) those parts carry over from the previous
// snapshot; the time-dependent views are still recomputed when a widget draws.
import AsyncStorage from '@react-native-async-storage/async-storage';
import { jmapClient } from '../api/jmap-client';
import { CAPABILITIES, type CalendarEvent, type ContactCard } from '../api/types';
import { generateAccountId } from '../lib/account-utils';
import { generateEmailAvatarColor, getEmailInitials } from '../lib/avatar-utils';
import { getEventEndDate, getEventStartDate } from '../lib/calendar-utils';
import { getUserParticipantId, getUserStatus } from '../lib/calendar-participants';
import { getBirthday, getContactDisplayName, getDateParts } from '../lib/contact-utils';
import { notificationLocale } from '../lib/push-background-task';
import { useSettingsStore } from '../stores/settings-store';
import { useKeywordsStore, keywordToken } from '../stores/keywords-store';
import { LIGHT_COLORS } from '../theme/tokens';
import { addDays, startOfDay } from './format';
import { startOfWeek } from './derive';
import { fetchInboxPreview, fetchMailSection, openClient, type MailSection } from './jmap';
import { normalizeHex } from './theme';
import { storeRefresh } from './state';
import { uiStarted } from './ui-presence';
import {
emptySnapshot,
loadSnapshot,
type AccountSummary,
type Birthday,
type EventItem,
type FileItem,
type Invitation,
type ScheduledItem,
type TagCount,
type TaskItem,
type WidgetSnapshot,
} from './snapshot';
interface RegistryAccount {
id: string;
email?: string;
displayName?: string;
username?: string;
avatarColor?: string;
}
export async function readRegistry(): Promise<{ accounts: RegistryAccount[]; activeAccountId: string | null }> {
try {
const raw = await AsyncStorage.getItem('account-registry');
const state = raw ? (JSON.parse(raw) as { state?: { accounts?: RegistryAccount[]; activeAccountId?: string | null } }).state : null;
const accounts = Array.isArray(state?.accounts) ? state!.accounts! : [];
const active = state?.activeAccountId && accounts.some((a) => a.id === state.activeAccountId)
? state.activeAccountId
: accounts[0]?.id ?? null;
return { accounts, activeAccountId: active };
} catch {
return { accounts: [], activeAccountId: null };
}
}
async function readRecentSearches(): Promise<string[]> {
try {
const raw = await AsyncStorage.getItem('search-history-storage');
const list = raw ? (JSON.parse(raw) as { state?: { recentSearches?: unknown } }).state?.recentSearches : null;
return Array.isArray(list) ? list.filter((s): s is string => typeof s === 'string').slice(0, 8) : [];
} catch {
return [];
}
}
async function readPendingChanges(accountId: string): Promise<number> {
try {
const raw = await AsyncStorage.getItem(`webmail:outbox:v1:${accountId}`);
const list = raw ? JSON.parse(raw) : null;
return Array.isArray(list) ? list.length : 0;
} catch {
return 0;
}
}
/** True when the app's singleton is signed in to `registryAccountId` (live runtime). */
export function singletonServes(registryAccountId: string): boolean {
if (!jmapClient.isConnected) return false;
const { username, serverUrl } = jmapClient;
return !!username && !!serverUrl && generateAccountId(username, serverUrl) === registryAccountId;
}
function tagColor(color: string): string {
const tags = LIGHT_COLORS.tags;
return (color in tags ? tags[color as keyof typeof tags] : tags.gray).dot;
}
function tagCounts(section: MailSection): TagCount[] {
const { keywords } = useKeywordsStore.getState();
const out: TagCount[] = [];
for (const kw of keywords) {
const counts = section.tagCounts.get(keywordToken(kw.id));
if (!counts || counts.total === 0) continue;
out.push({
keyword: keywordToken(kw.id),
name: kw.label,
color: tagColor(kw.color),
unread: counts.unread,
total: counts.total,
...(counts.latest ? { latestFrom: counts.latest.fromName, latestAt: counts.latest.receivedAt } : {}),
});
}
return out.sort((a, b) => b.unread - a.unread || b.total - a.total);
}
// ── App-backed sections (live runtime only) ──────────────────────────────
function participantAddress(p: { email?: string; calendarAddress?: string; sendTo?: Record<string, string> }): string {
const raw = p.email || p.calendarAddress || p.sendTo?.imip || '';
return raw.replace(/^mailto:/i, '');
}
function toEventItem(
event: CalendarEvent,
calendarsById: Map<string, { name: string; color?: string }>,
selfEmails: string[],
accountId: string,
): EventItem {
const calId = Object.keys(event.calendarIds ?? {}).find((id) => event.calendarIds[id]);
const cal = calId ? calendarsById.get(calId) : undefined;
const start = getEventStartDate(event).getTime();
const end = Math.max(start, getEventEndDate(event).getTime());
const location = Object.values(event.locations ?? {}).find((l) => l?.name)?.name;
const video = Object.values(event.virtualLocations ?? {}).find((v) => /^https?:\/\//i.test(v?.uri ?? ''));
const self = new Set(selfEmails.map((e) => e.toLowerCase()));
// Other people only: "who is coming" and the running-late mail leave the
// user out.
const participants = Object.values(event.participants ?? {})
.filter((p) => !self.has(participantAddress(p).toLowerCase()))
.map((p) => {
const email = participantAddress(p);
const name = p.name?.trim() || email;
return {
name,
email,
initials: getEmailInitials(p.name ?? '', email) || '?',
color: normalizeHex(generateEmailAvatarColor(p.name ?? '', email)),
};
})
.filter((p) => p.name);
const organizer = Object.values(event.participants ?? {}).find((p) => p.roles?.owner);
const status = getUserStatus(event, selfEmails);
// A timed event from midnight to midnight is a whole-day event in all but
// name (some clients write holidays that way); show it as all-day rather
// than as "00:00 - 00:00".
const wholeDays = !event.showWithoutTime
&& start === startOfDay(start)
&& end > start
&& end === startOfDay(end);
return {
id: event.id,
serverId: event.originalId || event.id,
accountId,
...(event.accountId ? { jmapAccountId: event.accountId } : {}),
title: event.title || '',
start,
end,
allDay: !!event.showWithoutTime || wholeDays,
color: normalizeHex(event.color || cal?.color),
calendarName: cal?.name ?? '',
...(location ? { location } : {}),
...(video ? { videoUrl: video.uri, videoName: video.name || '' } : {}),
participants,
...(status === 'needs-action' || status === 'accepted' || status === 'tentative' || status === 'declined'
? { myStatus: status }
: {}),
...(organizer ? { organizerName: organizer.name || participantAddress(organizer) } : {}),
};
}
async function buildCalendar(
now: number,
selfEmails: string[],
previous: WidgetSnapshot,
accountId: string,
): Promise<WidgetSnapshot['calendar']> {
const calendarsSupported = jmapClient.hasAccountCapability(CAPABILITIES.CALENDARS);
if (!calendarsSupported) return { supported: false, events: [], invitations: [], birthdays: await buildBirthdays(now) };
const { useCalendarStore, loadEventsInRange } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
// Always refetch: the persisted list can predate a calendar deleted and
// re-created on the server under the same id, which would put events under
// the old name and colour.
try {
await useCalendarStore.getState().fetchCalendars();
} catch {
// fall back to the cached list
}
const { calendars, hiddenCalendarIds } = useCalendarStore.getState();
const visible = calendars.filter((c) => !hiddenCalendarIds.includes(c.id));
const byId = new Map(calendars.map((c) => [c.id, { name: c.name, color: c.color }]));
const first = new Date(now);
first.setDate(1);
const rangeStart = startOfWeek(first.getTime(), useSettingsStore.getState().calendarFirstDayOfWeek === 0 ? 0 : 1);
const rangeEnd = Math.max(addDays(startOfDay(now), 36), addDays(rangeStart, 42));
let raw: CalendarEvent[];
try {
raw = visible.length === 0
? []
: await loadEventsInRange(calendars, visible.map((c) => c.id), new Date(rangeStart).toISOString(), new Date(rangeEnd).toISOString());
} catch (err) {
console.warn('[widgets] calendar refresh failed', err);
return { ...previous.calendar, birthdays: await buildBirthdays(now) };
}
const live = raw.filter((e) => e.status !== 'cancelled');
const events = live
.map((e) => toEventItem(e, byId, selfEmails, accountId))
.filter((e) => e.end >= rangeStart && e.start < rangeEnd)
.sort((a, b) => a.start - b.start);
// One entry per series: answering an invitation answers the whole event.
const invitations: Invitation[] = [];
const seen = new Set<string>();
for (const e of live) {
if (getUserStatus(e, selfEmails) !== 'needs-action') continue;
const participantId = getUserParticipantId(e, selfEmails);
const key = e.originalId || e.id;
if (!participantId || seen.has(key)) continue;
const item = toEventItem(e, byId, selfEmails, accountId);
if (item.end < now) continue;
seen.add(key);
invitations.push({ ...item, participantId });
}
return {
supported: true,
events,
invitations: invitations.sort((a, b) => a.start - b.start).slice(0, 10),
birthdays: await buildBirthdays(now),
};
}
async function buildBirthdays(now: number): Promise<Birthday[]> {
const { useContactsStore } = require('../stores/contacts-store') as typeof import('../stores/contacts-store');
// The Contacts tab loads the address book lazily; the widget may be first.
if (jmapClient.isConnected) {
try {
await useContactsStore.getState().fetchContactsIfStale();
} catch {
// use whatever is cached
}
}
const contacts: ContactCard[] = useContactsStore.getState().contacts ?? [];
const today = startOfDay(now);
const out: Birthday[] = [];
for (const contact of contacts) {
const date = getBirthday(contact);
if (!date) continue;
const { month, day } = getDateParts(date);
if (!month || !day) continue;
const thisYear = new Date(new Date(today).getFullYear(), month - 1, day).getTime();
const next = thisYear >= today ? thisYear : new Date(new Date(today).getFullYear() + 1, month - 1, day).getTime();
const name = getContactDisplayName(contact);
const email = Object.values(contact.emails ?? {})[0]?.address;
out.push({
name,
...(email ? { email } : {}),
initials: getEmailInitials(name, email) || '?',
color: normalizeHex(generateEmailAvatarColor(name, email)),
date: next,
});
}
return out.sort((a, b) => a.date - b.date).slice(0, 20);
}
async function buildTasks(previous: WidgetSnapshot, accountId: string, reload: boolean): Promise<WidgetSnapshot['tasks']> {
if (!useSettingsStore.getState().enableCalendarTasks) return { supported: false, items: [] };
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
try {
const store = useCalendarStore.getState();
if (reload || store.tasks.length === 0) await store.fetchTasks();
const { tasks, calendars } = useCalendarStore.getState();
const byId = new Map(calendars.map((c) => [c.id, c]));
const items: TaskItem[] = tasks
.filter((t) => t.progress !== 'cancelled')
.map((t) => {
const calId = Object.keys(t.calendarIds ?? {}).find((id) => t.calendarIds[id]);
const cal = calId ? byId.get(calId) : undefined;
// A LocalDateTime without an offset parses as device-local time.
const due = t.due ? Date.parse(t.due) : NaN;
return {
id: t.id,
serverId: t.originalId || t.id,
accountId,
...(t.accountId ? { jmapAccountId: t.accountId } : {}),
title: t.title || '',
...(Number.isFinite(due) ? { due } : {}),
dueHasTime: !!t.due && !t.showWithoutTime && !/T00:00(:00)?$/.test(t.due),
done: t.progress === 'completed',
calendarName: cal?.name ?? '',
color: normalizeHex(t.color || cal?.color),
};
});
return { supported: true, items: items.slice(0, 40) };
} catch {
return previous.tasks;
}
}
async function buildFiles(previous: WidgetSnapshot): Promise<WidgetSnapshot['files']> {
const files = require('../api/files') as typeof import('../api/files');
if (!files.supportsFiles()) return { supported: false, items: [] };
try {
const nodes = await files.getAllFileNodes();
const items: FileItem[] = nodes
.filter((n) => !!n.name)
.map((n) => ({
id: n.id,
name: n.name,
isFolder: files.isFolder(n),
size: n.size ?? 0,
modified: Date.parse(n.modified || n.created || '') || 0,
type: n.type || '',
}))
.sort((a, b) => b.modified - a.modified)
.slice(0, 6);
return { supported: true, items };
} catch {
return previous.files;
}
}
async function buildScheduled(previous: WidgetSnapshot): Promise<ScheduledItem[]> {
try {
if (!jmapClient.hasDelayedSend()) return [];
const { listScheduledEmails } = require('../api/email') as typeof import('../api/email');
const list = await listScheduledEmails();
return list.slice(0, 6).map((s) => {
const to = s.to?.[0];
return {
id: s.emailSubmissionId,
to: to?.name || to?.email || '',
subject: s.subject ?? '',
sendAt: Date.parse(s.sendAt) || 0,
};
});
} catch {
return previous.mail.scheduled;
}
}
// ── Assembly ─────────────────────────────────────────────────────────────
async function buildSnapshot(previous: WidgetSnapshot): Promise<WidgetSnapshot> {
const now = Date.now();
const { accounts, activeAccountId } = await readRegistry();
if (!activeAccountId) return { ...emptySnapshot(), generatedAt: now };
const settings = useSettingsStore.getState();
if (!settings.hydrated) await settings.hydrate();
const keywordsState = useKeywordsStore.getState();
if (!keywordsState.hydrated) await keywordsState.hydrate();
const { theme, timeFormat, calendarFirstDayOfWeek } = useSettingsStore.getState();
// With no UI in this runtime (a widget or push task started it), sign the
// app's client in to the active account, so calendar and tasks load as
// they do in the app instead of staying as the app last left them.
const borrowed = !singletonServes(activeAccountId) && !uiStarted() && (await borrowSingleton(activeAccountId));
const live = borrowed || singletonServes(activeAccountId);
const client = live ? jmapClient : await openClient(activeAccountId);
const next: WidgetSnapshot = {
...previous,
version: 1,
generatedAt: now,
signedIn: true,
theme,
locale: await notificationLocale(),
hour12: timeFormat === '12h',
weekStart: calendarFirstDayOfWeek === 0 ? 0 : 1,
activeAccountId,
};
if (previous.activeAccountId !== activeAccountId) {
// Nothing of another account's data may linger in a widget.
const blank = emptySnapshot();
next.mail = blank.mail;
next.calendar = blank.calendar;
next.tasks = blank.tasks;
next.files = blank.files;
next.vacation = null;
next.quota = null;
next.appDataAt = 0;
}
if (!client) return next;
const keywordTokens = useKeywordsStore.getState().keywords.map((k) => keywordToken(k.id));
const section = await fetchMailSection(client, activeAccountId, keywordTokens);
const others = accounts.filter((a) => a.id !== activeAccountId);
const previews = await Promise.all(others.map(async (a) => {
try {
const c = await openClient(a.id);
return c ? { account: a, ...(await fetchInboxPreview(c, a.id)) } : null;
} catch {
return null;
}
}));
const inboxUnread = section.folders.find((f) => f.role === 'inbox')?.unread ?? 0;
const summaries: AccountSummary[] = accounts.map((a) => {
const preview = previews.find((p) => p?.account.id === a.id);
return {
id: a.id,
label: a.displayName || a.email || a.username || a.id,
color: normalizeHex(a.avatarColor),
unread: a.id === activeAccountId ? inboxUnread : preview?.unread ?? 0,
};
});
const unified = [
...section.inbox.slice(0, 6),
...previews.flatMap((p) => p?.items ?? []),
].sort((a, b) => b.receivedAt - a.receivedAt).slice(0, 8);
next.accounts = summaries;
next.mail = {
...next.mail,
folders: section.folders,
inbox: section.inbox,
unified,
starred: section.starred,
starredCount: section.starredCount,
drafts: section.drafts,
draftCount: section.draftCount,
favourites: section.favourites,
recentSearches: await readRecentSearches(),
tags: tagCounts(section),
attachments: section.attachments,
pendingChanges: await readPendingChanges(activeAccountId),
};
next.vacation = section.vacation;
next.quota = section.quota;
if (live) {
const [calendar, tasks, files, scheduled] = await Promise.all([
buildCalendar(now, section.selfEmails, next, activeAccountId),
buildTasks(next, activeAccountId, borrowed),
buildFiles(next),
buildScheduled(next),
]);
next.calendar = calendar;
next.tasks = tasks;
next.files = files;
next.mail.scheduled = scheduled;
next.appDataAt = now;
}
return next;
}
async function borrowSingleton(registryAccountId: string): Promise<boolean> {
try {
if (!(await jmapClient.loadAccount(registryAccountId))) return false;
} catch {
// Offline, or credentials the app will deal with when it opens.
return false;
}
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
const { useContactsStore } = require('../stores/contacts-store') as typeof import('../stores/contacts-store');
await Promise.all([useCalendarStore.getState().hydrate(), useContactsStore.getState().hydrate()]).catch(() => undefined);
return jmapClient.isConnected;
}
let inflight: Promise<WidgetSnapshot> | null = null;
/**
* Fetch fresh data and store it. Concurrent callers share one run: every
* placed widget gets its own update broadcast, and they would otherwise each
* start a refresh. On failure the previous snapshot is returned unchanged.
* Changes widget buttons made meanwhile are kept over the result (./state.ts).
*
* `after` is for callers that just changed something on the server (a widget
* button): a run already in flight may have read the server before that
* change, so they wait for it and start (or join) the next one instead.
*/
export async function refreshSnapshot(opts?: { after?: 'change' }): Promise<WidgetSnapshot> {
if (opts?.after === 'change' && inflight) {
await inflight.catch(() => undefined);
return refreshSnapshot();
}
if (inflight) return inflight;
inflight = (async () => {
const startedAt = Date.now();
const previous = (await loadSnapshot()) ?? emptySnapshot();
try {
const next = await buildSnapshot(previous);
await storeRefresh(next, startedAt);
return next;
} catch (err) {
console.warn('[widgets] refresh failed', err);
return previous;
} finally {
inflight = null;
}
})();
return inflight;
}