diff --git a/index.ts b/index.ts index 5dfad1b2..c40fc96b 100644 --- a/index.ts +++ b/index.ts @@ -6,6 +6,7 @@ import { registerWidgetTaskHandler } from 'react-native-android-widget'; import { pushBackgroundTask } from './src/lib/push-background-task'; import { widgetTaskHandler } from './src/widgets/task-handler'; import { refreshWidgetsInBackground } from './src/widgets/sync'; +import { markUiStarted } from './src/widgets/ui-presence'; // Runs in a fresh headless JS runtime when BulwarkPushTaskService is started // from BulwarkMessagingService on an FCM data message. Must be registered @@ -30,6 +31,7 @@ registerWidgetTaskHandler(widgetTaskHandler); // UI first renders instead of imported above: importing it here would load // every screen, store and icon before the task could run. function Root() { + markUiStarted(); const App = (require('./App') as typeof import('./App')).default; return createElement(App); } diff --git a/src/widgets/build.ts b/src/widgets/build.ts index 50275466..46f6dddd 100644 --- a/src/widgets/build.ts +++ b/src/widgets/build.ts @@ -1,10 +1,11 @@ // Builds the widget snapshot. Mail comes from JMAP on a client of the widgets' -// own (./jmap.ts) and works from a cold headless start. Calendar, tasks, -// birthdays, files and scheduled sends reuse the app's helpers, which are -// bound to the `jmapClient` singleton; they only run when that singleton is -// already connected to the active account (the app's JS runtime is alive). -// Otherwise those parts carry over from the previous snapshot, and the -// time-dependent views are still recomputed when a widget draws. +// 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'; @@ -23,6 +24,7 @@ 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, @@ -269,12 +271,12 @@ async function buildBirthdays(now: number): Promise { return out.sort((a, b) => a.date - b.date).slice(0, 20); } -async function buildTasks(previous: WidgetSnapshot, accountId: string): Promise { +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 (store.tasks.length === 0) await store.fetchTasks(); + 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 @@ -358,7 +360,11 @@ async function buildSnapshot(previous: WidgetSnapshot): Promise if (!keywordsState.hydrated) await keywordsState.hydrate(); const { theme, timeFormat, calendarFirstDayOfWeek } = useSettingsStore.getState(); - const live = singletonServes(activeAccountId); + // 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, @@ -433,7 +439,7 @@ async function buildSnapshot(previous: WidgetSnapshot): Promise if (live) { const [calendar, tasks, files, scheduled] = await Promise.all([ buildCalendar(now, section.selfEmails, next, activeAccountId), - buildTasks(next, activeAccountId), + buildTasks(next, activeAccountId, borrowed), buildFiles(next), buildScheduled(next), ]); @@ -446,6 +452,19 @@ async function buildSnapshot(previous: WidgetSnapshot): Promise return next; } +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; /** diff --git a/src/widgets/ui-presence.ts b/src/widgets/ui-presence.ts new file mode 100644 index 00000000..3a3f2187 --- /dev/null +++ b/src/widgets/ui-presence.ts @@ -0,0 +1,15 @@ +// Whether the app's UI has started in this JS runtime. Widget and push tasks +// can start the runtime with no UI; until the UI starts, nothing else uses +// the app's `jmapClient` singleton, so a widget refresh may sign it in to +// the active account and load calendar and tasks with the app's own code. + +let started = false; + +/** Called when the UI renders for the first time (index.ts). */ +export function markUiStarted(): void { + started = true; +} + +export function uiStarted(): boolean { + return started; +}