From ae3121a74675f8106aab33abe354b41761bc1122 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:01:20 +0200 Subject: [PATCH] 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. --- index.ts | 2 ++ src/widgets/build.ts | 39 ++++++++++++++++++++++++++++---------- src/widgets/ui-presence.ts | 15 +++++++++++++++ 3 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 src/widgets/ui-presence.ts 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; +}