// 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, type FileNode } 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 { isFeatureEnabled, resolvePolicy, sanitizePolicy } from '../lib/org-policy'; 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 { resolveAvatarImages } from './avatar-images'; 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; webmailUrl?: string; } /** * Whether the account's organisation lets it archive (policy * archiveActionEnabled), from the policy the app cached for it * (stores/policy-store.ts, which this runtime may not have loaded). On * without a webmail or a cached policy from it. */ export async function readArchiveEnabled(account: RegistryAccount | undefined): Promise { if (!account?.webmailUrl) return true; try { const raw = await AsyncStorage.getItem('org-policy-cache'); const byAccount = raw ? (JSON.parse(raw) as { state?: { byAccount?: Record } }).state?.byAccount : undefined; const cached = byAccount?.[account.id]; const policy = cached?.webmailUrl === account.webmailUrl ? sanitizePolicy(cached.policy) : null; if (!policy) return true; const email = (account.email || account.username || '').trim().toLowerCase(); return isFeatureEnabled(resolvePolicy(policy, { email, groups: [] }), 'archiveActionEnabled'); } catch { return true; } } 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 { 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 { 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 { const raw = p.email || p.calendarAddress || p.sendTo?.imip || ''; return raw.replace(/^mailto:/i, ''); } function toEventItem( event: CalendarEvent, calendarsById: Map, 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 { 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(); 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 { 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 { 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; } } /** * The Recent files widget's rows: the newest nodes of the user's own drive. * The Files trash (lib/files-trash) and what is in it stay out, as they do * in the Files tab. The helpers come in as arguments because they are loaded * only once the widget needs them. */ export function recentFileItems( nodes: FileNode[], files: Pick, trash: Pick, ): FileItem[] { return trash.withoutTrash(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); } async function buildFiles(previous: WidgetSnapshot): Promise { const files = require('../api/files') as typeof import('../api/files'); if (!files.supportsFiles()) return { supported: false, items: [] }; try { const trash = require('../lib/files-trash') as typeof import('../lib/files-trash'); return { supported: true, items: recentFileItems(await files.getAllFileNodes(), files, trash) }; } catch { return previous.files; } } async function buildScheduled(previous: WidgetSnapshot): Promise { 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 { 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, archiveEnabled: await readArchiveEnabled(accounts.find((a) => a.id === 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.images = undefined; 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; } next.images = await avatarImages(next, useSettingsStore.getState().senderFavicons); return next; } /** Pictures for every avatar the widgets can show (see ./avatar-images.ts). */ async function avatarImages(s: WidgetSnapshot, favicons: boolean): Promise { const emails = [ ...s.mail.inbox.map((m) => m.fromEmail), ...s.mail.unified.map((m) => m.fromEmail), ...s.mail.starred.map((m) => m.fromEmail), ...s.mail.favourites.map((p) => p.email), ...s.calendar.events.flatMap((e) => e.participants.map((x) => x.email)), ...s.calendar.birthdays.map((b) => b.email ?? ''), ].filter(Boolean); try { const { useContactsStore } = require('../stores/contacts-store') as typeof import('../stores/contacts-store'); const contacts = useContactsStore.getState(); if (!contacts.hydrated) await contacts.hydrate(); return await resolveAvatarImages(emails, { contacts: useContactsStore.getState().contacts ?? [], favicons }); } catch (err) { console.warn('[widgets] avatar pictures failed', err); return s.images; } } async function borrowSingleton(registryAccountId: string): Promise { 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 | 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 { 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; }