diff --git a/App.tsx b/App.tsx index e00140b3..bc631fa8 100644 --- a/App.tsx +++ b/App.tsx @@ -69,7 +69,10 @@ import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt'; import { ToastHost } from './src/components/ToastHost'; import { UndoSnackbar } from './src/components/UndoSnackbar'; import { AppIconBadge } from './src/components/AppIconBadge'; -import { getEmails } from './src/api/email'; +import { getEmails, getFullEmail } from './src/api/email'; +import { signOutWidgets, startWidgetSync } from './src/widgets/sync'; +import { draftContextFromEmail } from './src/lib/draft-context'; +import { replyComposeParams } from './src/lib/reply-compose'; import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking'; import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent'; import { OfflineCacheBanner } from './src/components/OfflineCacheBanner'; @@ -133,14 +136,34 @@ async function openCalendarReminder(target: CalendarReminderTarget): Promise { await handleDeepLink(link, { navigation: navigationRef, - resolveThreadId: async (emailId) => { + resolveThreadId: async (emailId, jmapAccountId) => { try { - const [email] = await getEmails([emailId]); + const [email] = await getEmails([emailId], jmapAccountId); return email?.threadId ?? null; } catch { return null; } }, + openReply: async (emailId, jmapAccountId) => { + try { + const params = replyComposeParams('reply', await getFullEmail(emailId, jmapAccountId), jmapAccountId); + if (!params || !navigationRef.isReady()) return false; + navigationRef.navigate('Compose', params); + return true; + } catch { + return false; + } + }, + openDraft: async (emailId, jmapAccountId) => { + try { + const draft = draftContextFromEmail(await getFullEmail(emailId, jmapAccountId), jmapAccountId); + if (!navigationRef.isReady()) return false; + navigationRef.navigate('Compose', { draft }); + return true; + } catch { + return false; + } + }, switchAccount: async (accountId) => { const auth = useAuthStore.getState(); if (auth.activeAccountId === accountId) return true; @@ -385,6 +408,14 @@ export default function App() { return useNetworkStore.getState().init(); }, []); + // Home-screen widgets follow the signed-in data; once the last account is + // gone they are wiped so no mail stays visible on the launcher. + React.useEffect(() => { + if (isAuthenticated) return startWidgetSync(); + if (hasRestoredSession) void signOutWidgets(); + return undefined; + }, [isAuthenticated, hasRestoredSession]); + // When the network flips back on while we're authenticated-but-offline // (no live JMAP session), retry the session so the user lands back on // live data without needing to relaunch. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a1318459..1c2731e4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -66,5 +66,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/widget/WidgetProviders.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/widget/WidgetProviders.kt new file mode 100644 index 00000000..3a4efe0b --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/widget/WidgetProviders.kt @@ -0,0 +1,40 @@ +// Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. +// react-native-android-widget names a widget after its provider class, so each +// widget needs its own subclass; the JS side (src/widgets/task-handler.tsx) +// renders by that name. +package com.anonymous.bulwarkmobile.widget + +import com.reactnativeandroidwidget.RNWidgetProvider + +class InboxWidget : RNWidgetProvider() +class TriageWidget : RNWidgetProvider() +class UnreadCountWidget : RNWidgetProvider() +class LatestMessageWidget : RNWidgetProvider() +class FolderCountsWidget : RNWidgetProvider() +class MailShortcutsWidget : RNWidgetProvider() +class AllAccountsWidget : RNWidgetProvider() +class StarredWidget : RNWidgetProvider() +class FavouritePeopleWidget : RNWidgetProvider() +class MailSearchWidget : RNWidgetProvider() +class OutboxWidget : RNWidgetProvider() +class TagWidget : RNWidgetProvider() +class LastDraftWidget : RNWidgetProvider() +class AgendaWidget : RNWidgetProvider() +class WeekWidget : RNWidgetProvider() +class MonthWidget : RNWidgetProvider() +class TodayEventsWidget : RNWidgetProvider() +class NextEventWidget : RNWidgetProvider() +class DateNextWidget : RNWidgetProvider() +class UpNextWidget : RNWidgetProvider() +class InvitationsWidget : RNWidgetProvider() +class FreeTimeWidget : RNWidgetProvider() +class TasksWidget : RNWidgetProvider() +class TaskProgressWidget : RNWidgetProvider() +class CountdownWidget : RNWidgetProvider() +class BirthdaysWidget : RNWidgetProvider() +class TodayHubWidget : RNWidgetProvider() +class MailAndNextWidget : RNWidgetProvider() +class RecentFilesWidget : RNWidgetProvider() +class AttachmentsWidget : RNWidgetProvider() +class VacationWidget : RNWidgetProvider() +class StorageWidget : RNWidgetProvider() diff --git a/android/app/src/main/res/drawable-nodpi/agendawidget_preview.png b/android/app/src/main/res/drawable-nodpi/agendawidget_preview.png new file mode 100644 index 00000000..23530cdc Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/agendawidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/allaccountswidget_preview.png b/android/app/src/main/res/drawable-nodpi/allaccountswidget_preview.png new file mode 100644 index 00000000..07288c8e Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/allaccountswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/attachmentswidget_preview.png b/android/app/src/main/res/drawable-nodpi/attachmentswidget_preview.png new file mode 100644 index 00000000..c9b13319 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/attachmentswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/birthdayswidget_preview.png b/android/app/src/main/res/drawable-nodpi/birthdayswidget_preview.png new file mode 100644 index 00000000..4d572809 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/birthdayswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/countdownwidget_preview.png b/android/app/src/main/res/drawable-nodpi/countdownwidget_preview.png new file mode 100644 index 00000000..2ef9baf1 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/countdownwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/datenextwidget_preview.png b/android/app/src/main/res/drawable-nodpi/datenextwidget_preview.png new file mode 100644 index 00000000..82e3c71e Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/datenextwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/favouritepeoplewidget_preview.png b/android/app/src/main/res/drawable-nodpi/favouritepeoplewidget_preview.png new file mode 100644 index 00000000..026e9b71 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/favouritepeoplewidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/foldercountswidget_preview.png b/android/app/src/main/res/drawable-nodpi/foldercountswidget_preview.png new file mode 100644 index 00000000..0aed2541 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/foldercountswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/freetimewidget_preview.png b/android/app/src/main/res/drawable-nodpi/freetimewidget_preview.png new file mode 100644 index 00000000..95631e05 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/freetimewidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/inboxwidget_preview.png b/android/app/src/main/res/drawable-nodpi/inboxwidget_preview.png new file mode 100644 index 00000000..0ef3d992 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/inboxwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/invitationswidget_preview.png b/android/app/src/main/res/drawable-nodpi/invitationswidget_preview.png new file mode 100644 index 00000000..9e1573c5 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/invitationswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/lastdraftwidget_preview.png b/android/app/src/main/res/drawable-nodpi/lastdraftwidget_preview.png new file mode 100644 index 00000000..79642042 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/lastdraftwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/latestmessagewidget_preview.png b/android/app/src/main/res/drawable-nodpi/latestmessagewidget_preview.png new file mode 100644 index 00000000..63b680c8 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/latestmessagewidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/mailandnextwidget_preview.png b/android/app/src/main/res/drawable-nodpi/mailandnextwidget_preview.png new file mode 100644 index 00000000..14f5c077 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/mailandnextwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/mailsearchwidget_preview.png b/android/app/src/main/res/drawable-nodpi/mailsearchwidget_preview.png new file mode 100644 index 00000000..b74ce6df Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/mailsearchwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/mailshortcutswidget_preview.png b/android/app/src/main/res/drawable-nodpi/mailshortcutswidget_preview.png new file mode 100644 index 00000000..e1a0a3d3 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/mailshortcutswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/monthwidget_preview.png b/android/app/src/main/res/drawable-nodpi/monthwidget_preview.png new file mode 100644 index 00000000..f386255d Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/monthwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/nexteventwidget_preview.png b/android/app/src/main/res/drawable-nodpi/nexteventwidget_preview.png new file mode 100644 index 00000000..28eb7c5d Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/nexteventwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/outboxwidget_preview.png b/android/app/src/main/res/drawable-nodpi/outboxwidget_preview.png new file mode 100644 index 00000000..e3530b98 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/outboxwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/recentfileswidget_preview.png b/android/app/src/main/res/drawable-nodpi/recentfileswidget_preview.png new file mode 100644 index 00000000..7796ef45 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/recentfileswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/starredwidget_preview.png b/android/app/src/main/res/drawable-nodpi/starredwidget_preview.png new file mode 100644 index 00000000..dbe92bdd Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/starredwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/storagewidget_preview.png b/android/app/src/main/res/drawable-nodpi/storagewidget_preview.png new file mode 100644 index 00000000..29a3fa57 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/storagewidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/tagwidget_preview.png b/android/app/src/main/res/drawable-nodpi/tagwidget_preview.png new file mode 100644 index 00000000..f2b1feb4 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/tagwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/taskprogresswidget_preview.png b/android/app/src/main/res/drawable-nodpi/taskprogresswidget_preview.png new file mode 100644 index 00000000..db963368 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/taskprogresswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/taskswidget_preview.png b/android/app/src/main/res/drawable-nodpi/taskswidget_preview.png new file mode 100644 index 00000000..758c181a Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/taskswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/todayeventswidget_preview.png b/android/app/src/main/res/drawable-nodpi/todayeventswidget_preview.png new file mode 100644 index 00000000..18ed45db Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/todayeventswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/todayhubwidget_preview.png b/android/app/src/main/res/drawable-nodpi/todayhubwidget_preview.png new file mode 100644 index 00000000..4edb42ef Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/todayhubwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/triagewidget_preview.png b/android/app/src/main/res/drawable-nodpi/triagewidget_preview.png new file mode 100644 index 00000000..d3c6f1c9 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/triagewidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/unreadcountwidget_preview.png b/android/app/src/main/res/drawable-nodpi/unreadcountwidget_preview.png new file mode 100644 index 00000000..19e9e6be Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/unreadcountwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/upnextwidget_preview.png b/android/app/src/main/res/drawable-nodpi/upnextwidget_preview.png new file mode 100644 index 00000000..4fa8657f Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/upnextwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/vacationwidget_preview.png b/android/app/src/main/res/drawable-nodpi/vacationwidget_preview.png new file mode 100644 index 00000000..55cbcf1f Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/vacationwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/weekwidget_preview.png b/android/app/src/main/res/drawable-nodpi/weekwidget_preview.png new file mode 100644 index 00000000..2ad380dc Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/weekwidget_preview.png differ diff --git a/android/app/src/main/res/values/widget_strings.xml b/android/app/src/main/res/values/widget_strings.xml new file mode 100644 index 00000000..c8011188 --- /dev/null +++ b/android/app/src/main/res/values/widget_strings.xml @@ -0,0 +1,68 @@ + + + + Inbox + Newest messages with sender, time and subject. Taller sizes add avatars and a line of preview. + Triage + One unread message at a time with archive, delete and reply buttons. + Unread count + Unread count for the inbox, with the latest senders. + Latest message + Only the newest unread message. + Folder counts + Unread counts for up to four folders. + Shortcuts + Compose, calendar, contacts and files. + All accounts + The unified inbox across every signed-in account. + Starred + Starred messages, newest first. + Favourite people + The people you hear from most, with their unread count. + Search mail + Search bar with your recent searches. + Scheduled and outbox + Scheduled messages and anything waiting in the outbox. + Tag + Unread count for your busiest tag. + Last draft + Back into the draft you left. + Agenda + Upcoming events by day with time, colour and place. + This week + The week at a glance from 8:00 to 18:00. + Month + Month grid with a dot per calendar and the next two events. + Today + Today\'s date with the rest of today and tomorrow. + Next event + What\'s next, when and where. + Date and next two + Today\'s date with the next two events. + Up next + Countdown to the next event with join and running-late buttons. + Invitations + Pending invitations with accept, maybe and decline. + Free time + When you\'re free this week. + Tasks + Open tasks with tick boxes that work from the home screen. + Task progress + How many tasks are done. + Countdown + Days until your next all-day event. + Birthdays + Upcoming birthdays from your contacts. + Today overview + Next event, newest unread mail and tasks due, in one widget. + Mail and next event + Unread mail on one side, the next event on the other. + Recent files + Recently changed items in Files. + Recent attachments + The newest attachments from your mail. + Vacation responder + Vacation responder status with an on/off switch. + Storage + How much of your mailbox quota is used. + diff --git a/android/app/src/main/res/xml/widgetprovider_agendawidget.xml b/android/app/src/main/res/xml/widgetprovider_agendawidget.xml new file mode 100644 index 00000000..dbe5aaa1 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_agendawidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_allaccountswidget.xml b/android/app/src/main/res/xml/widgetprovider_allaccountswidget.xml new file mode 100644 index 00000000..6a7f0518 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_allaccountswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_attachmentswidget.xml b/android/app/src/main/res/xml/widgetprovider_attachmentswidget.xml new file mode 100644 index 00000000..8a5a3539 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_attachmentswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_birthdayswidget.xml b/android/app/src/main/res/xml/widgetprovider_birthdayswidget.xml new file mode 100644 index 00000000..050009b7 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_birthdayswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_countdownwidget.xml b/android/app/src/main/res/xml/widgetprovider_countdownwidget.xml new file mode 100644 index 00000000..cb6f174f --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_countdownwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_datenextwidget.xml b/android/app/src/main/res/xml/widgetprovider_datenextwidget.xml new file mode 100644 index 00000000..2c97b57e --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_datenextwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_favouritepeoplewidget.xml b/android/app/src/main/res/xml/widgetprovider_favouritepeoplewidget.xml new file mode 100644 index 00000000..5195a43f --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_favouritepeoplewidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_foldercountswidget.xml b/android/app/src/main/res/xml/widgetprovider_foldercountswidget.xml new file mode 100644 index 00000000..d6acc336 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_foldercountswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_freetimewidget.xml b/android/app/src/main/res/xml/widgetprovider_freetimewidget.xml new file mode 100644 index 00000000..dfb312ec --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_freetimewidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_inboxwidget.xml b/android/app/src/main/res/xml/widgetprovider_inboxwidget.xml new file mode 100644 index 00000000..5602c37a --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_inboxwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_invitationswidget.xml b/android/app/src/main/res/xml/widgetprovider_invitationswidget.xml new file mode 100644 index 00000000..8a19f0f2 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_invitationswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_lastdraftwidget.xml b/android/app/src/main/res/xml/widgetprovider_lastdraftwidget.xml new file mode 100644 index 00000000..d6868790 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_lastdraftwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_latestmessagewidget.xml b/android/app/src/main/res/xml/widgetprovider_latestmessagewidget.xml new file mode 100644 index 00000000..b3f29e33 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_latestmessagewidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_mailandnextwidget.xml b/android/app/src/main/res/xml/widgetprovider_mailandnextwidget.xml new file mode 100644 index 00000000..5e36edbe --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_mailandnextwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_mailsearchwidget.xml b/android/app/src/main/res/xml/widgetprovider_mailsearchwidget.xml new file mode 100644 index 00000000..7fb3e15e --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_mailsearchwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_mailshortcutswidget.xml b/android/app/src/main/res/xml/widgetprovider_mailshortcutswidget.xml new file mode 100644 index 00000000..d0dbca8f --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_mailshortcutswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_monthwidget.xml b/android/app/src/main/res/xml/widgetprovider_monthwidget.xml new file mode 100644 index 00000000..0e298659 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_monthwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_nexteventwidget.xml b/android/app/src/main/res/xml/widgetprovider_nexteventwidget.xml new file mode 100644 index 00000000..6f6a8e8c --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_nexteventwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_outboxwidget.xml b/android/app/src/main/res/xml/widgetprovider_outboxwidget.xml new file mode 100644 index 00000000..8635b1df --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_outboxwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_recentfileswidget.xml b/android/app/src/main/res/xml/widgetprovider_recentfileswidget.xml new file mode 100644 index 00000000..afa8712b --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_recentfileswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_starredwidget.xml b/android/app/src/main/res/xml/widgetprovider_starredwidget.xml new file mode 100644 index 00000000..a69ef5d7 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_starredwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_storagewidget.xml b/android/app/src/main/res/xml/widgetprovider_storagewidget.xml new file mode 100644 index 00000000..98fd3fd9 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_storagewidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_tagwidget.xml b/android/app/src/main/res/xml/widgetprovider_tagwidget.xml new file mode 100644 index 00000000..4e9275f6 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_tagwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_taskprogresswidget.xml b/android/app/src/main/res/xml/widgetprovider_taskprogresswidget.xml new file mode 100644 index 00000000..3a15f164 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_taskprogresswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_taskswidget.xml b/android/app/src/main/res/xml/widgetprovider_taskswidget.xml new file mode 100644 index 00000000..ecb6a9a4 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_taskswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_todayeventswidget.xml b/android/app/src/main/res/xml/widgetprovider_todayeventswidget.xml new file mode 100644 index 00000000..961a96be --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_todayeventswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_todayhubwidget.xml b/android/app/src/main/res/xml/widgetprovider_todayhubwidget.xml new file mode 100644 index 00000000..c053e67a --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_todayhubwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_triagewidget.xml b/android/app/src/main/res/xml/widgetprovider_triagewidget.xml new file mode 100644 index 00000000..0d731a32 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_triagewidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_unreadcountwidget.xml b/android/app/src/main/res/xml/widgetprovider_unreadcountwidget.xml new file mode 100644 index 00000000..c39dcd8d --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_unreadcountwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_upnextwidget.xml b/android/app/src/main/res/xml/widgetprovider_upnextwidget.xml new file mode 100644 index 00000000..cc956c56 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_upnextwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_vacationwidget.xml b/android/app/src/main/res/xml/widgetprovider_vacationwidget.xml new file mode 100644 index 00000000..18a0ee3f --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_vacationwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_weekwidget.xml b/android/app/src/main/res/xml/widgetprovider_weekwidget.xml new file mode 100644 index 00000000..6541f415 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_weekwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/index.ts b/index.ts index c68d2075..e1ae8026 100644 --- a/index.ts +++ b/index.ts @@ -1,13 +1,22 @@ import { createElement } from 'react'; import { AppRegistry } from 'react-native'; import { registerRootComponent } from 'expo'; +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'; // Runs in a fresh headless JS runtime when BulwarkPushTaskService is started // from BulwarkMessagingService on an FCM data message. Must be registered -// before the native service tries to invoke the task. -AppRegistry.registerHeadlessTask('BulwarkPushTask', () => pushBackgroundTask); +// before the native service tries to invoke the task. New mail also refreshes +// any home-screen widgets (a no-op when none are placed). +AppRegistry.registerHeadlessTask('BulwarkPushTask', () => async (data: Parameters[0]) => { + await Promise.allSettled([pushBackgroundTask(data), refreshWidgetsInBackground()]); +}); + +// Home-screen widgets (Android): every widget event runs this headless task. +registerWidgetTaskHandler(widgetTaskHandler); // A headless push task evaluates this file too, so App is required when the // UI first renders instead of imported above: importing it here would load diff --git a/locales/rn/en.json b/locales/rn/en.json index ecbfe47f..cabbed7d 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1038,5 +1038,188 @@ "severity_deprecated": "Required", "severity_security": "Security", "title": "App updates" + }, + "widgets": { + "attachments": { + "empty": "No recent attachments", + "from_mail": "from mail", + "title": "Attachments", + "unknown_sender": "Unknown sender" + }, + "birthdays": { + "empty": "No birthdays coming up", + "empty_body": "Birthdays come from your contacts.", + "in_days": "{count, plural, one {in # day} other {in # days}}", + "send_wishes": "Send wishes", + "title": "Birthdays", + "wishes_subject": "Happy birthday!" + }, + "calendar": { + "agenda": "Agenda", + "agenda_more": "{day}: {events}", + "all_day": "All day", + "join": "Join", + "join_named": "Join {name}", + "more_this_week": "{count, plural, one {# more this week} other {# more this week}}", + "no_more_today": "No more events today", + "nothing_planned": "Nothing on the calendar", + "now": "Now", + "open": "Open", + "running_late": "Running late", + "running_late_body": "I'm running a few minutes late for {title}.", + "running_late_subject": "Running late: {title}", + "today": "Today", + "tomorrow": "Tomorrow", + "unit_days": "{count, plural, one {day} other {days}}", + "unit_hours": "{count, plural, one {hour} other {hours}}", + "unit_minutes": "{count, plural, one {min} other {min}}", + "unsupported": "Calendar isn't available on this server", + "untitled": "(no title)", + "video_call": "Video call" + }, + "countdown": { + "days": "{count, plural, one {day} other {days}}", + "empty": "No upcoming events" + }, + "draft": { + "edited_at": "Edited {when}", + "edited_minutes": "{count, plural, one {Edited # min ago} other {Edited # min ago}}", + "edited_now": "Edited just now", + "empty": "No drafts", + "empty_body": "Messages you start and do not send wait here.", + "keep_writing": "Keep writing", + "title": "Drafts" + }, + "files": { + "empty": "No files yet", + "recent": "Recently changed", + "unsupported": "Files aren't available on this server" + }, + "free": { + "busy_until": "Busy until {time}", + "free_rest": "Free for the rest of the day", + "free_until": "Free until {time}", + "mail": "Mail my free times", + "mail_subject": "My free times this week", + "no_calendar": "No calendar on this account", + "then": "then {title}", + "then_free": "then free" + }, + "hub": { + "all_clear": "You're all caught up", + "mail_unread": "Mail · {count} unread", + "next": "Next", + "next_on": "Next · {day}", + "no_events": "No upcoming events", + "now": "Now", + "tasks_due": "Tasks · {count} due" + }, + "invites": { + "accept": "Accept", + "decline": "Decline", + "empty": "No invitations waiting", + "label": "Invitation · {name}", + "label_plain": "Invitation", + "maybe": "Maybe", + "position": "{index} of {count}" + }, + "mail": { + "all_accounts": "All accounts", + "all_caught_up": "All caught up", + "archive": "Archive", + "delete": "Delete", + "empty_all_accounts": "Your inboxes are empty", + "empty_inbox": "Your inbox is empty", + "folders": "Folders", + "in_outbox": "{count, plural, one {# change waiting} other {# changes waiting}}", + "in_outbox_short": "{count} waiting", + "inbox": "Inbox", + "more_unread": "+{count} more unread", + "no_starred": "No starred mail", + "no_starred_body": "Star a message to keep it here.", + "no_subject": "(no subject)", + "no_unread": "No unread messages in your inbox.", + "position": "{index} of {count}", + "reply": "Reply", + "scheduled": "Scheduled", + "starred": "Starred", + "unread": "unread" + }, + "people": { + "empty": "No favourites yet", + "empty_body": "The people you hear from most show up here.", + "favourites": "Favourites" + }, + "relative": { + "days": "{count, plural, one {in # day} other {in # days}}", + "hours": "in {count} h", + "minutes": "in {count} min", + "now": "now" + }, + "scheduled": { + "day_at": "{day}, {time}", + "empty": "Nothing scheduled", + "empty_body": "Messages you schedule to send later show up here.", + "no_recipient": "(no recipient)", + "sending": "Sending now", + "to": "To: {name}", + "today_at": "Today, {time}", + "tomorrow_at": "Tomorrow, {time}", + "waiting": "Waiting for network", + "waiting_body": "Changes you made offline go out when you are back online." + }, + "search": { + "no_recent": "Your recent searches show up here.", + "placeholder": "Search mail…", + "recent": "Recent searches" + }, + "shortcuts": { + "calendar": "Calendar", + "compose": "Compose", + "contacts": "Contacts", + "files": "Files" + }, + "state": { + "error": "Couldn't show this widget", + "loading": "Loading…", + "open_app": "Open the app once if this stays empty.", + "open_to_load": "Open Bulwark to load this", + "signed_out": "Sign in to Bulwark" + }, + "storage": { + "no_limit": "No storage limit", + "title": "Storage", + "unavailable": "No quota information", + "used_of": "{used} of {total}" + }, + "tag": { + "empty": "No tags yet", + "empty_body": "Tags you add in the app show up here.", + "messages": "{count, plural, one {message} other {messages}}", + "total": "{count, plural, one {# message} other {# messages}}" + }, + "tasks": { + "all_done": "All done", + "empty": "No open tasks", + "mark_complete": "Mark as done", + "mark_incomplete": "Mark as not done", + "no_title": "(no title)", + "off": "Tasks are off", + "off_body": "Turn on tasks in the app's calendar settings", + "open_count": "{count} open", + "overdue": "Overdue", + "still_open": "{count} still open", + "title": "Tasks", + "today": "Today", + "tomorrow": "Tomorrow" + }, + "vacation": { + "active": "Active", + "inactive": "Inactive", + "subject": "Subject: {subject}", + "title": "Vacation Responder", + "unsupported": "Vacation replies aren't available", + "until": "Until {date}" + } } } diff --git a/package-lock.json b/package-lock.json index 0880af09..533444d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "postal-mime": "^3.0.0", "react": "19.1.0", "react-native": "0.81.5", + "react-native-android-widget": "0.22.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-sse": "^1.2.1", @@ -9004,6 +9005,22 @@ } } }, + "node_modules/react-native-android-widget": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/react-native-android-widget/-/react-native-android-widget-0.22.1.tgz", + "integrity": "sha512-BBMuqShRrxV23Z09Rn5zNHJaPegCnH3DhhZQgXbhw2GMh45Gez0RbwKTIWHftuW4nLrqWy6esz8XwecUZLiZKA==", + "license": "MIT", + "peerDependencies": { + "expo": ">=54.0.0", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, "node_modules/react-native-is-edge-to-edge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", diff --git a/package.json b/package.json index 5d9fb4e1..02c2662e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "bump": "node scripts/bump-version.mjs", "sync-locales": "node scripts/sync-locales.mjs", "i18n:harvest": "node scripts/harvest-rn-keys.mjs", - "i18n:check": "node scripts/harvest-rn-keys.mjs --check" + "i18n:check": "node scripts/harvest-rn-keys.mjs --check", + "widgets:sync": "node scripts/sync-android-widgets.mjs" }, "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", @@ -45,6 +46,7 @@ "postal-mime": "^3.0.0", "react": "19.1.0", "react-native": "0.81.5", + "react-native-android-widget": "0.22.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-sse": "^1.2.1", diff --git a/scripts/sync-android-widgets.mjs b/scripts/sync-android-widgets.mjs new file mode 100644 index 00000000..28678dc6 --- /dev/null +++ b/scripts/sync-android-widgets.mjs @@ -0,0 +1,193 @@ +// Generate the Android side of the home-screen widgets from +// src/widgets/catalog.json. Run from the RN repo root: +// node scripts/sync-android-widgets.mjs [--check] +// +// android/ is committed and hand-maintained (no `expo prebuild`), so the +// react-native-android-widget config plugin never runs for it. This script +// does the same job: one RNWidgetProvider subclass per widget (the library +// uses the class name as the widget name), a provider XML per widget, the +// picker labels and descriptions, and the manifest receivers between the +// BEGIN/END WIDGETS markers. With --check it only reports drift and exits 1. +import { readFile, writeFile, readdir, unlink, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const CATALOG = join(ROOT, 'src', 'widgets', 'catalog.json'); +const MAIN = join(ROOT, 'android', 'app', 'src', 'main'); +const PACKAGE = 'com.anonymous.bulwarkmobile'; +const KOTLIN = join(MAIN, 'java', ...PACKAGE.split('.'), 'widget', 'WidgetProviders.kt'); +const XML_DIR = join(MAIN, 'res', 'xml'); +const STRINGS = join(MAIN, 'res', 'values', 'widget_strings.xml'); +// Optional picker previews: src/widgets/previews/.png is copied to +// res/drawable-nodpi and set as the widget's previewImage. +const PREVIEWS = join(ROOT, 'src', 'widgets', 'previews'); +const PREVIEW_DIR = join(MAIN, 'res', 'drawable-nodpi'); +const MANIFEST = join(MAIN, 'AndroidManifest.xml'); +const CHECK_ONLY = process.argv.includes('--check'); + +// Android's sizing table: n cells is roughly 70n - 30 dp in either direction. +const dp = (cells) => 70 * cells - 30; +// Launchers refresh at most every 30 minutes; the app pushes fresher data +// itself whenever it has some (see src/widgets/sync.ts). +const UPDATE_PERIOD_MS = 30 * 60 * 1000; + +const escapeXml = (s) => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, "\\'"); +const resName = (name) => `widgetprovider_${name.toLowerCase()}`; + +const hasPreview = (w) => existsSync(join(PREVIEWS, `${w.name}.png`)); +const previewRes = (w) => `${w.name.toLowerCase()}_preview`; + +function providerXml(w) { + const preview = hasPreview(w) ? `\n android:previewImage="@drawable/${previewRes(w)}"` : ''; + return ` + + + +`; +} + +function kotlinFile(widgets) { + const classes = widgets.map((w) => `class ${w.name} : RNWidgetProvider()`).join('\n'); + return `// Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. +// react-native-android-widget names a widget after its provider class, so each +// widget needs its own subclass; the JS side (src/widgets/task-handler.tsx) +// renders by that name. +package ${PACKAGE}.widget + +import com.reactnativeandroidwidget.RNWidgetProvider + +${classes} +`; +} + +function stringsFile(widgets) { + const lines = widgets.flatMap((w) => [ + ` ${escapeXml(w.label)}`, + ` ${escapeXml(w.description)}`, + ]); + return ` + + +${lines.join('\n')} + +`; +} + +function manifestBlock(widgets) { + const receivers = widgets.map( + (w) => ` + + + + + + `, + ); + return ` + +${receivers.join('\n')} + `; +} + +function withManifestBlock(manifest, block) { + const begin = manifest.indexOf(' '; + const end = manifest.indexOf(endMarker); + if (begin !== -1 && end !== -1) { + return manifest.slice(0, begin) + block + manifest.slice(end + endMarker.length); + } + const close = manifest.lastIndexOf(' '); + if (close === -1) throw new Error('AndroidManifest.xml has no '); + return manifest.slice(0, close) + block + '\n' + manifest.slice(close); +} + +const { widgets } = JSON.parse(await readFile(CATALOG, 'utf8')); +const names = new Set(); +for (const w of widgets) { + if (!/^[A-Z][A-Za-z0-9]*Widget$/.test(w.name)) throw new Error(`Bad widget name ${w.name}`); + if (names.has(w.name)) throw new Error(`Duplicate widget name ${w.name}`); + names.add(w.name); +} + +const outputs = new Map(); +outputs.set(KOTLIN, kotlinFile(widgets)); +outputs.set(STRINGS, stringsFile(widgets)); +for (const w of widgets) outputs.set(join(XML_DIR, `${resName(w.name)}.xml`), providerXml(w)); +outputs.set(MANIFEST, withManifestBlock(await readFile(MANIFEST, 'utf8'), manifestBlock(widgets))); + +// Provider XML for widgets that were removed from the catalog. +const stale = existsSync(XML_DIR) + ? (await readdir(XML_DIR)) + .filter((f) => f.startsWith('widgetprovider_')) + .map((f) => join(XML_DIR, f)) + .filter((f) => !outputs.has(f)) + : []; + +// Preview images are binary; compare them byte for byte. +const binaryOutputs = new Map(); +for (const w of widgets) { + if (hasPreview(w)) binaryOutputs.set(join(PREVIEW_DIR, `${previewRes(w)}.png`), await readFile(join(PREVIEWS, `${w.name}.png`))); +} +const stalePreviews = existsSync(PREVIEW_DIR) + ? (await readdir(PREVIEW_DIR)) + .filter((f) => /widget_preview\.png$/.test(f)) + .map((f) => join(PREVIEW_DIR, f)) + .filter((f) => !binaryOutputs.has(f)) + : []; +stale.push(...stalePreviews); + +let drift = stale.length; +for (const [path, bytes] of binaryOutputs) { + const current = existsSync(path) ? await readFile(path) : null; + if (current && current.equals(bytes)) continue; + drift++; + if (CHECK_ONLY) { + console.log(`out of date: ${path}`); + } else { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes); + console.log(`wrote ${path}`); + } +} +// Checkouts with core.autocrlf have CRLF files: compare without line endings +// and write back in whatever the file already uses. +const lf = (text) => text.replace(/\r\n/g, '\n'); +for (const [path, content] of outputs) { + const current = existsSync(path) ? await readFile(path, 'utf8') : null; + if (current !== null && lf(current) === lf(content)) continue; + drift++; + if (CHECK_ONLY) { + console.log(`out of date: ${path}`); + } else { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, current?.includes('\r\n') ? lf(content).replace(/\n/g, '\r\n') : lf(content)); + console.log(`wrote ${path}`); + } +} +for (const path of stale) { + if (CHECK_ONLY) console.log(`stale: ${path}`); + else { + await unlink(path); + console.log(`removed ${path}`); + } +} + +if (CHECK_ONLY && drift > 0) { + console.error(`${drift} widget file(s) out of date; run node scripts/sync-android-widgets.mjs`); + process.exit(1); +} +console.log(drift === 0 ? 'Android widget files are up to date.' : `${widgets.length} widgets synced.`); diff --git a/src/lib/reply-compose.ts b/src/lib/reply-compose.ts new file mode 100644 index 00000000..dc762589 --- /dev/null +++ b/src/lib/reply-compose.ts @@ -0,0 +1,50 @@ +import type { Email } from '../api/types'; +import type { RootStackParamList } from '../navigation/types'; +import { pickEmailBody, plainTextBody } from './email-body'; + +/** + * Composer params for a reply / reply-all / forward of `source`. Shared by + * the thread viewer and the `mail/message/?action=reply` deep link (the + * triage widget). Null when there is nobody to reply to. + */ +export function replyComposeParams( + mode: 'reply' | 'replyAll' | 'forward', + source: Email, + ownerAccountId?: string, +): NonNullable | null { + const from = source.from?.[0]; + if (!from && mode !== 'forward') return null; + // Quote the HTML part when there is one so layout and inline images + // survive (#163). RFC 8621 §4.1.4: an HTML-only message exposes the same + // part in `textBody` and `htmlBody`, so the text part is only a real + // alternative when its partId differs - otherwise the composer would be + // handed raw HTML source (#649, native #46). + const picked = pickEmailBody(source); + const quoteHtml = picked.html ?? undefined; + const body = !quoteHtml || picked.text ? plainTextBody(source) : undefined; + return { + mode, + replyTo: { + from: from ?? { email: '' }, + to: source.to, + cc: source.cc, + // RFC 5322: a reply goes to Reply-To when the sender set one. + replyToAddresses: source.replyTo, + subject: source.subject ?? '', + body, + htmlBody: quoteHtml, + receivedAt: source.receivedAt, + sentAt: source.sentAt, + // Threading needs the RFC Message-ID, never the JMAP object id (#234). + messageId: source.messageId ?? undefined, + references: source.references ?? undefined, + // Forward carries the original attachments as blob refs; cid-embedded + // inline images are already part of the quoted HTML. + attachments: mode === 'forward' + ? (source.attachments ?? []).filter((a) => !(a.disposition === 'inline' && a.cid)) + : undefined, + originalEmailId: source.id, + jmapAccountId: ownerAccountId, + }, + }; +} diff --git a/src/navigation/__tests__/linking.test.ts b/src/navigation/__tests__/linking.test.ts index 07a64684..a701f962 100644 --- a/src/navigation/__tests__/linking.test.ts +++ b/src/navigation/__tests__/linking.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { handleDeepLink, parseDeepLink, shareToDeepLink } from '../linking'; import { usePendingSettingsTab } from '../pending-settings-tab'; import { usePendingCalendarOpen } from '../pending-calendar-open'; +import { usePendingMailSearch } from '../pending-mail-search'; describe('parseDeepLink', () => { it('parses app-scheme mail links', () => { @@ -75,6 +76,18 @@ describe('parseDeepLink', () => { }); }); + it('parses the widget links: reply, draft, unified views, scheduled and search', () => { + expect(parseDeepLink('bulwarkmobile://mail/message/M1?account=a%40b&jmapAccount=team&action=reply')).toEqual({ + kind: 'message', emailId: 'M1', accountId: 'a@b', jmapAccountId: 'team', action: 'reply', + }); + expect(parseDeepLink('bulwarkmobile://mail/draft/D1')).toEqual({ kind: 'draft', emailId: 'D1', accountId: undefined }); + expect(parseDeepLink('bulwarkmobile://mail/unified?view=starred')).toEqual({ kind: 'unified', view: 'starred' }); + expect(parseDeepLink('bulwarkmobile://mail/unified?role=drafts&view=bogus')).toEqual({ kind: 'unified', role: 'drafts' }); + expect(parseDeepLink('bulwarkmobile://mail/scheduled')).toEqual({ kind: 'scheduled' }); + expect(parseDeepLink('bulwarkmobile://mail/search?q=from%3Aada')).toEqual({ kind: 'search', query: 'from:ada' }); + expect(parseDeepLink('bulwarkmobile://mail/search')).toEqual({ kind: 'search', query: '' }); + }); + it('rejects unknown links', () => { expect(parseDeepLink('bulwarkmobile://whatever')).toBeNull(); expect(parseDeepLink('garbage')).toBeNull(); @@ -166,6 +179,40 @@ describe('handleDeepLink', () => { })); }); + it('opens a group-mailbox message in its JMAP account and hands replies to the composer opener', async () => { + const navigation = nav(); + const resolveThreadId = vi.fn(async () => 'T1'); + await handleDeepLink( + { kind: 'message', emailId: 'M1', jmapAccountId: 'team' }, + { navigation: navigation as never, resolveThreadId }, + ); + expect(resolveThreadId).toHaveBeenCalledWith('M1', 'team'); + expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M1', threadId: 'T1', jmapAccountId: 'team' }); + + const openReply = vi.fn(async () => true); + const ok = await handleDeepLink( + { kind: 'message', emailId: 'M2', action: 'reply' }, + { navigation: nav() as never, resolveThreadId, openReply }, + ); + expect(ok).toBe(true); + expect(openReply).toHaveBeenCalledWith('M2', undefined); + }); + + it('opens drafts, unified views, scheduled mail and searches', async () => { + const openDraft = vi.fn(async () => true); + const navigation = nav(); + const deps = { navigation: navigation as never, resolveThreadId: async () => 'T', openDraft }; + expect(await handleDeepLink({ kind: 'draft', emailId: 'D1' }, deps)).toBe(true); + expect(openDraft).toHaveBeenCalledWith('D1', undefined); + await handleDeepLink({ kind: 'unified', view: 'starred' }, deps); + expect(navigation.navigate).toHaveBeenCalledWith('UnifiedInbox', { view: 'starred' }); + await handleDeepLink({ kind: 'scheduled' }, deps); + expect(navigation.navigate).toHaveBeenCalledWith('Scheduled'); + await handleDeepLink({ kind: 'search', query: 'from:ada' }, deps); + expect(usePendingMailSearch.getState().consume()).toBe('from:ada'); + expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Mail' }); + }); + it('refuses when the linked account is not signed in', async () => { const navigation = nav(); const ok = await handleDeepLink( diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index 14a8bf48..9149f94d 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -8,13 +8,25 @@ import { parseMailtoUrl } from '../lib/mailto'; import type { RootStackParamList } from './types'; import { setPendingSettingsTab } from './pending-settings-tab'; import { setPendingCalendarOpen } from './pending-calendar-open'; +import { setPendingMailSearch } from './pending-mail-search'; export const APP_SCHEME = 'bulwarkmobile'; +const UNIFIED_ROLES = ['inbox', 'sent', 'drafts', 'junk', 'archive', 'trash'] as const; +const UNIFIED_VIEWS = ['all', 'unread', 'starred'] as const; +type UnifiedRole = typeof UNIFIED_ROLES[number]; +type UnifiedView = typeof UNIFIED_VIEWS[number]; + export type DeepLink = - | { kind: 'message'; emailId: string; accountId?: string } + // `jmapAccountId`: the JMAP account owning a message in a group/shared + // mailbox (`?jmapAccount=`); `action: 'reply'` opens a reply to it. + | { kind: 'message'; emailId: string; accountId?: string; jmapAccountId?: string; action?: 'reply' } + | { kind: 'draft'; emailId: string; accountId?: string; jmapAccountId?: string } | { kind: 'thread'; threadId: string; accountId?: string } | { kind: 'folder'; ref: string; accountId?: string } + | { kind: 'unified'; role?: UnifiedRole; view?: UnifiedView } + | { kind: 'scheduled' } + | { kind: 'search'; query: string } // `jmapAccountId`: the JMAP account owning the event (`?account=`), for // one on a calendar shared with the user. | { kind: 'calendar'; eventId?: string; date?: string; jmapAccountId?: string } @@ -82,7 +94,30 @@ export function parseDeepLink(url: string): DeepLink | null { switch (area) { case 'mail': { - if (kind === 'message' && value) return { kind: 'message', emailId: decodeSegment(value), accountId }; + const jmapAccountId = search.get('jmapAccount') ?? undefined; + if (kind === 'message' && value) { + return { + kind: 'message', + emailId: decodeSegment(value), + accountId, + ...(jmapAccountId ? { jmapAccountId } : {}), + ...(search.get('action') === 'reply' ? { action: 'reply' as const } : {}), + }; + } + if (kind === 'draft' && value) { + return { kind: 'draft', emailId: decodeSegment(value), accountId, ...(jmapAccountId ? { jmapAccountId } : {}) }; + } + if (kind === 'unified') { + const role = search.get('role'); + const view = search.get('view'); + return { + kind: 'unified', + ...(role && (UNIFIED_ROLES as readonly string[]).includes(role) ? { role: role as UnifiedRole } : {}), + ...(view && (UNIFIED_VIEWS as readonly string[]).includes(view) ? { view: view as UnifiedView } : {}), + }; + } + if (kind === 'scheduled') return { kind: 'scheduled' }; + if (kind === 'search') return { kind: 'search', query: search.get('q') ?? '' }; if (kind === 'thread' && value) return { kind: 'thread', threadId: decodeSegment(value), accountId }; if (kind === 'folder' && value) return { kind: 'folder', ref: decodeSegment(value), accountId }; // Legacy `?email=` from the webmail's older service worker. @@ -131,8 +166,13 @@ export function parseDeepLink(url: string): DeepLink | null { export interface DeepLinkNavigator { navigation: NavigationContainerRefWithCurrent; // Resolve a message id to its thread (EmailThread needs both). Returns - // null when the message cannot be loaded. - resolveThreadId: (emailId: string) => Promise; + // null when the message cannot be loaded. `jmapAccountId` names the + // group/shared account holding it, when it is not the user's own. + resolveThreadId: (emailId: string, jmapAccountId?: string) => Promise; + // Open the composer on a reply to / on a server draft of this message. + // Resolve false when the message cannot be loaded. + openReply?: (emailId: string, jmapAccountId?: string) => Promise; + openDraft?: (emailId: string, jmapAccountId?: string) => Promise; // Switch to the account a permalink names (`?account=`); resolves false // when that account is not signed in on this device. switchAccount?: (accountId: string) => Promise; @@ -149,11 +189,33 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr switch (link.kind) { case 'message': { - const threadId = await nav.resolveThreadId(link.emailId); + if (link.action === 'reply' && nav.openReply) { + return nav.openReply(link.emailId, link.jmapAccountId); + } + const threadId = await nav.resolveThreadId(link.emailId, link.jmapAccountId); if (!threadId) return false; - navigation.navigate('EmailThread', { emailId: link.emailId, threadId }); + navigation.navigate('EmailThread', { + emailId: link.emailId, + threadId, + ...(link.jmapAccountId ? { jmapAccountId: link.jmapAccountId } : {}), + }); return true; } + case 'draft': + return nav.openDraft ? nav.openDraft(link.emailId, link.jmapAccountId) : false; + case 'unified': + navigation.navigate('UnifiedInbox', { + ...(link.role ? { role: link.role } : {}), + ...(link.view ? { view: link.view } : {}), + }); + return true; + case 'scheduled': + navigation.navigate('Scheduled'); + return true; + case 'search': + setPendingMailSearch(link.query); + navigation.navigate('MainTabs', { screen: 'Mail' } as never); + return true; case 'thread': // The reader keys on the message; without one, open the list. navigation.navigate('MainTabs', { screen: 'Mail' } as never); diff --git a/src/navigation/pending-mail-search.ts b/src/navigation/pending-mail-search.ts new file mode 100644 index 00000000..1f5677ae --- /dev/null +++ b/src/navigation/pending-mail-search.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand'; + +// A `mail/search` deep link (the search widget) arrives before the mail list +// is mounted, or while it shows a folder. The query is parked here and +// consumed by EmailListScreen, which runs it like a submitted search; an +// empty query just opens the search field. +interface PendingMailSearchState { + query: string | null; + set: (query: string | null) => void; + consume: () => string | null; +} + +export const usePendingMailSearch = create((set, get) => ({ + query: null, + set: (query) => set({ query }), + consume: () => { + const query = get().query; + if (query !== null) set({ query: null }); + return query; + }, +})); + +export function setPendingMailSearch(query: string | null): void { + usePendingMailSearch.getState().set(query); +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 969a31f1..9a399042 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -49,6 +49,7 @@ import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context'; import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email'; import type { RootStackParamList } from '../navigation/types'; +import { usePendingMailSearch } from '../navigation/pending-mail-search'; import type { Attachment, Email } from '../api/types'; function getSenderName(email: Email, unknownLabel: string): string { @@ -824,6 +825,17 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL if (value.trim()) addRecentSearch(value); setSearchFocused(false); }, [setSearchQuery, addRecentSearch]); + // A search handed over by a deep link (the search widget): run it, or with + // no query just put the cursor in the search field. + const searchInputRef = React.useRef(null); + const pendingSearch = usePendingMailSearch((s) => s.query); + React.useEffect(() => { + if (pendingSearch === null) return; + const query = usePendingMailSearch.getState().consume(); + if (query === null) return; + if (query.trim()) submitSearch(query); + else setTimeout(() => searchInputRef.current?.focus(), 300); + }, [pendingSearch, submitSearch]); // People whose name/address matches what is being typed (#845); picking // one turns the search into a `from:` filter like the webmail. const contactSuggestions = React.useMemo(() => { @@ -1120,6 +1132,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL { const source = target ?? email; if (!source) return; - const from = source.from?.[0]; - if (!from && mode !== 'forward') return; - // Quote the HTML part when there is one so layout and inline images - // survive (#163). RFC 8621 §4.1.4: an HTML-only message exposes the same - // part in `textBody` and `htmlBody`, so the text part is only a real - // alternative when its partId differs - otherwise the composer would be - // handed raw HTML source (#649, native #46). - const picked = pickEmailBody(source); - const quoteHtml = picked.html ?? undefined; - const body = !quoteHtml || picked.text ? plainTextBody(source) : undefined; - navigation.navigate('Compose', { - mode, - replyTo: { - from: from ?? { email: '' }, - to: source.to, - cc: source.cc, - // RFC 5322: a reply goes to Reply-To when the sender set one. - replyToAddresses: source.replyTo, - subject: source.subject ?? '', - body, - htmlBody: quoteHtml, - receivedAt: source.receivedAt, - sentAt: source.sentAt, - // Threading needs the RFC Message-ID, never the JMAP object id (#234). - messageId: source.messageId ?? undefined, - references: source.references ?? undefined, - // Forward carries the original attachments as blob refs; cid-embedded - // inline images are already part of the quoted HTML. - attachments: mode === 'forward' - ? (source.attachments ?? []).filter((a) => !(a.disposition === 'inline' && a.cid)) - : undefined, - originalEmailId: source.id, - jmapAccountId: ownerAccountId, - }, - }); + const params = replyComposeParams(mode, source, ownerAccountId); + if (params) navigation.navigate('Compose', params); }, [email, navigation, ownerAccountId]); // Forward the raw message as a message/rfc822 attachment (webmail 1.8.1). diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 47d51270..f1009c4f 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -552,8 +552,11 @@ export function spansAccounts(state: Pick */ export function spannedAccounts(mailboxes: Mailbox[]): Array { const out: Array = [undefined]; + // The drawer renders the persisted mailboxes before the session is back, + // and `accountId` throws while the client is not connected. + const own = jmapClient.isConnected ? jmapClient.accountId : undefined; for (const m of mailboxes) { - if (m.isShared && m.accountId && m.accountId !== jmapClient.accountId && !out.includes(m.accountId)) { + if (m.isShared && m.accountId && m.accountId !== own && !out.includes(m.accountId)) { out.push(m.accountId); } } diff --git a/src/widgets/__tests__/derive.test.ts b/src/widgets/__tests__/derive.test.ts new file mode 100644 index 00000000..154ff5cd --- /dev/null +++ b/src/widgets/__tests__/derive.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { + agendaDays, + busyBlocks, + countdownTarget, + dueKind, + eventsOnDay, + freeTime, + monthGrid, + nextEvent, + sortTasks, + startOfWeek, +} from '../derive'; +import type { EventItem, TaskItem } from '../snapshot'; + +// Monday 28 September 2026, 09:05 local time. +const NOW = new Date(2026, 8, 28, 9, 5).getTime(); +const at = (day: number, h: number, m = 0) => new Date(2026, 8, day, h, m).getTime(); + +function ev(id: string, start: number, end: number, extra: Partial = {}): EventItem { + return { + id, + serverId: id, + title: id, + start, + end, + allDay: false, + color: '#22c55e', + calendarName: 'Work', + participants: [], + ...extra, + }; +} + +const standup = ev('standup', at(28, 9, 30), at(28, 10)); +const dentist = ev('dentist', at(29, 10), at(29, 11), { color: '#3b82f6' }); +const lunch = ev('lunch', at(30, 12, 30), at(30, 13, 30)); +const party = ev('party', new Date(2026, 9, 1).getTime(), new Date(2026, 9, 2).getTime(), { allDay: true, color: '#3b82f6' }); +const review = ev('review', new Date(2026, 9, 1, 14).getTime(), new Date(2026, 9, 1, 16).getTime()); +const trip = ev('trip', new Date(2026, 9, 4).getTime(), new Date(2026, 9, 5).getTime(), { allDay: true, color: '#3b82f6' }); +const earlier = ev('earlier', at(28, 7), at(28, 8)); +const EVENTS = [earlier, standup, dentist, lunch, party, review, trip]; + +describe('calendar views', () => { + it('finds the next timed event and whether it is running', () => { + expect(nextEvent(EVENTS, NOW)?.event.id).toBe('standup'); + expect(nextEvent(EVENTS, NOW)?.running).toBe(false); + expect(nextEvent(EVENTS, at(28, 9, 45))?.running).toBe(true); + }); + + it('lists a day with all-day events first', () => { + const thursday = new Date(2026, 9, 1).getTime(); + expect(eventsOnDay(EVENTS, thursday).map((e) => e.id)).toEqual(['party', 'review']); + }); + + it('drops finished events from today in the agenda', () => { + const days = agendaDays(EVENTS, NOW, 3); + expect(days.map((d) => d.events.map((e) => e.id))).toEqual([['standup'], ['dentist'], ['lunch']]); + }); + + it('starts the week on the configured day', () => { + expect(new Date(startOfWeek(NOW, 1)).getDate()).toBe(28); + expect(new Date(startOfWeek(NOW, 0)).getDate()).toBe(27); + }); + + it('builds the month as whole weeks with dots and today', () => { + const weeks = monthGrid(EVENTS, NOW, 1); + expect(weeks).toHaveLength(5); + expect(weeks[0][0].day).toBe(31); + expect(weeks[0][0].inMonth).toBe(false); + const today = weeks.flat().find((c) => c.isToday); + expect(today?.day).toBe(28); + expect(today?.dots).toEqual(['#22c55e']); + }); + + it('places busy blocks inside working hours', () => { + const [block] = busyBlocks(EVENTS, new Date(2026, 8, 29).getTime()); + expect(block.from).toBeCloseTo(0.2); + expect(block.to).toBeCloseTo(0.3); + }); + + it('works out free time and the longest gap left today', () => { + const free = freeTime(EVENTS, NOW); + expect(free.busyNow).toBe(false); + expect(free.next?.id).toBe('standup'); + expect(free.longestGap).toEqual({ from: at(28, 10), to: at(28, 18) }); + }); + + it('counts down to the next all-day or multi-day event', () => { + expect(countdownTarget(EVENTS, NOW)?.id).toBe('party'); + }); +}); + +describe('tasks', () => { + const task = (id: string, due: number | undefined, done = false, dueHasTime = false): TaskItem => ({ + id, serverId: id, title: id, due, dueHasTime, done, calendarName: 'Personal', color: '#3b82f6', + }); + + it('classifies due dates', () => { + expect(dueKind(task('a', at(26, 0)), NOW)).toBe('overdue'); + expect(dueKind(task('b', at(28, 0)), NOW)).toBe('today'); + expect(dueKind(task('c', at(28, 8), false, true), NOW)).toBe('overdue'); + expect(dueKind(task('d', at(29, 0)), NOW)).toBe('tomorrow'); + expect(dueKind(task('e', undefined), NOW)).toBe('none'); + }); + + it('puts overdue first and done last', () => { + const sorted = sortTasks([ + task('done', at(20, 0), true), + task('later', new Date(2026, 9, 3).getTime()), + task('none', undefined), + task('today', at(28, 0)), + task('overdue', at(26, 0)), + ], NOW); + expect(sorted.map((t) => t.id)).toEqual(['overdue', 'today', 'later', 'none', 'done']); + }); +}); diff --git a/src/widgets/__tests__/jmap.test.ts b/src/widgets/__tests__/jmap.test.ts new file mode 100644 index 00000000..132b14b2 --- /dev/null +++ b/src/widgets/__tests__/jmap.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import type { Email, Mailbox } from '../../api/types'; +import { folderCounts, rankPeople, toMailItem } from '../jmap'; + +function email(id: string, from: string, name: string, seen: boolean, receivedAt = '2026-09-28T08:00:00Z'): Email { + return { + id, + threadId: `t-${id}`, + mailboxIds: { inbox: true }, + keywords: seen ? { $seen: true } : {}, + receivedAt, + from: [{ email: from, name }], + subject: `Subject ${id}`, + preview: ' line one\n line two ', + hasAttachment: false, + size: 1, + } as Email; +} + +describe('toMailItem', () => { + it('maps a list email to what the widgets draw', () => { + const item = toMailItem(email('1', 'sofia.russo@example.com', 'Sofia Russo', false), 'acc', { threadSize: 2 }); + expect(item).toMatchObject({ + id: '1', + accountId: 'acc', + fromName: 'Sofia Russo', + initials: 'SR', + unread: true, + threadSize: 2, + preview: 'line one line two', + }); + // Same hue as the webmail's Avatar for this name. + expect(item.color).toBe('#6826d9'); + }); +}); + +describe('rankPeople', () => { + it('ranks senders by volume, skips the user and no-reply senders, and counts unread', () => { + const people = rankPeople([ + email('1', 'ada@example.com', 'Ada', false), + email('2', 'ada@example.com', 'Ada', true), + email('3', 'bob@example.com', 'Bob', false), + email('4', 'me@example.com', 'Me', false), + email('5', 'noreply@shop.example', 'Shop', false), + ], ['Me@example.com']); + expect(people.map((p) => [p.email, p.unread])).toEqual([['ada@example.com', 1], ['bob@example.com', 1]]); + }); +}); + +describe('folderCounts', () => { + it('orders the roles like the sidebar and finds junk by role or name', () => { + const boxes = [ + { id: 'j', name: 'Spam', role: 'junk', unreadEmails: 3, totalEmails: 3 }, + { id: 'i', name: 'Inbox', role: 'inbox', unreadEmails: 10, totalEmails: 23 }, + { id: 'd', name: 'Drafts', role: 'drafts', unreadEmails: 0, totalEmails: 2 }, + ] as Mailbox[]; + expect(folderCounts(boxes).map((f) => [f.role, f.unread, f.total])).toEqual([ + ['inbox', 10, 23], + ['drafts', 0, 2], + ['junk', 3, 3], + ]); + }); +}); diff --git a/src/widgets/__tests__/layouts.test.ts b/src/widgets/__tests__/layouts.test.ts new file mode 100644 index 00000000..c7b24767 --- /dev/null +++ b/src/widgets/__tests__/layouts.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest'; + +// The package entry pulls in the native module, so hand the layouts the plain +// widget components and run them through the library's own tree builder: it +// is what throws on the device when a layout returns null, puts null in a +// mapped list or uses a Fragment. Colours are checked on the way in, because +// the library quietly turns any it cannot parse (a name, `transparent`) into +// white. +vi.mock('react-native-android-widget', async () => { + const COLOR = /^(#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|rgba\(.*\))$/i; + const checkColors = (widget: string, style: Record = {}) => { + for (const [key, value] of Object.entries(style)) { + if (/colou?r$/i.test(key) && !(typeof value === 'string' && COLOR.test(value))) { + throw new Error(`${widget} ${key}: ${JSON.stringify(value)} is not a colour the widget renderer reads`); + } + } + }; + type Widget = { __name__: string; convertProps: (props: { style?: Record }) => unknown }; + const checked = (module: Record) => { + for (const value of Object.values(module)) { + if (typeof value !== 'function' || !('__name__' in value)) continue; + const widget = value as unknown as Widget; + const convert = widget.convertProps; + widget.convertProps = (props) => { + checkColors(widget.__name__, props.style); + return convert(props); + }; + } + return module; + }; + // The compiled SvgWidget requires react-native (past the stub) to resolve + // bundled images; the widgets only draw inline SVG strings, so rebuild it + // from the library's own style and click converters. + const { convertCommonStyle } = await import('react-native-android-widget/lib/commonjs/widgets/utils/style.utils' as string); + const { convertClickAction } = await import('react-native-android-widget/lib/commonjs/widgets/utils/click-action' as string); + function SvgWidget() { + return null; + } + SvgWidget.__name__ = 'SvgWidget'; + SvgWidget.convertProps = (props: { style?: object; svg: unknown }) => { + if (typeof props.svg !== 'string') throw new Error('widgets draw inline SVG strings only'); + return { ...convertCommonStyle(props.style ?? {}), ...convertClickAction(props), svgString: props.svg }; + }; + return { + ...checked(await import('react-native-android-widget/lib/commonjs/widgets/FlexWidget' as string)), + ...checked(await import('react-native-android-widget/lib/commonjs/widgets/ListWidget' as string)), + ...checked(await import('react-native-android-widget/lib/commonjs/widgets/OverlapWidget' as string)), + ...checked(await import('react-native-android-widget/lib/commonjs/widgets/TextWidget' as string)), + ...checked({ SvgWidget }), + getWidgetInfo: vi.fn(async () => []), + requestWidgetUpdate: vi.fn(async () => undefined), + }; +}); + +import { createElement } from 'react'; +import { FlexWidget } from 'react-native-android-widget'; +import catalog from '../catalog.json'; +import { renderFor } from '../render'; +import { PREVIEW_NOW, sampleSnapshot } from '../sample-snapshot'; +import { emptySnapshot, type WidgetSnapshot } from '../snapshot'; + +const { buildWidgetTree } = (await import( + 'react-native-android-widget/lib/commonjs/api/build-widget-tree' as string +)) as { buildWidgetTree: (tree: unknown) => { type: string } }; + +// Launcher cells to dp, roughly what a phone launcher reports. +const dp = (cells: number) => cells * 90 - 15; + +function build(name: string, s: WidgetSnapshot, cols: number, rows: number, local = {}) { + const trees = renderFor(name, s, { width: dp(cols), height: dp(rows), widgetId: 1 }, local, PREVIEW_NOW); + for (const tree of 'light' in trees ? [trees.light, trees.dark] : [trees]) { + expect(buildWidgetTree(tree).type).toBeTruthy(); + } +} + +const sizes = (w: (typeof catalog.widgets)[number]) => [ + [w.minCols, w.minRows], + [w.cols, w.rows], + [4, 4], + [5, 5], +]; + +describe('widget layouts', () => { + it('uses a builder that rejects what the device rejects', () => { + const Nothing = () => null; + expect(() => buildWidgetTree(createElement(Nothing))).toThrow(); + expect(() => buildWidgetTree(createElement(FlexWidget, null, createElement(FlexWidget), [null]))).toThrow(); + expect(() => buildWidgetTree(createElement(FlexWidget, { style: { backgroundColor: 'teal' } } as never))).toThrow(); + }); + + const sample = { ...sampleSnapshot(PREVIEW_NOW), theme: 'system' as const }; + const empty: WidgetSnapshot = { + ...emptySnapshot(), + generatedAt: PREVIEW_NOW, + appDataAt: PREVIEW_NOW, + signedIn: true, + theme: 'system', + accounts: [{ id: 'a', label: 'Work', color: '#2563eb', unread: 0 }], + activeAccountId: 'a', + calendar: { supported: true, events: [], invitations: [], birthdays: [] }, + tasks: { supported: true, items: [] }, + files: { supported: true, items: [] }, + }; + + it.each(catalog.widgets.map((w) => [w.name, w] as const))('%s builds with demo data at every size', (name, w) => { + for (const [cols, rows] of sizes(w)) build(name, sample, cols, rows); + }); + + it.each(catalog.widgets.map((w) => [w.name, w] as const))('%s builds with nothing to show', (name, w) => { + for (const [cols, rows] of sizes(w)) build(name, empty, cols, rows); + }); + + it('builds the signed-out, loading and not-loaded-yet states', () => { + for (const w of catalog.widgets) { + build(w.name, { ...emptySnapshot(), generatedAt: PREVIEW_NOW }, w.cols, w.rows); + build(w.name, emptySnapshot(), w.cols, w.rows); + build(w.name, { ...sample, appDataAt: 0 }, w.cols, w.rows); + } + }); + + it('builds the triage card on a message that is gone', () => { + build('TriageWidget', sample, 4, 2, { triageId: 'no-such-message' }); + build('TriageWidget', { ...sample, mail: { ...sample.mail, inbox: [] } }, 4, 2, { triageId: 'm1' }); + }); +}); diff --git a/src/widgets/actions.ts b/src/widgets/actions.ts new file mode 100644 index 00000000..ec506ddc --- /dev/null +++ b/src/widgets/actions.ts @@ -0,0 +1,163 @@ +// Buttons that act without opening the app: archive / delete / next on the +// triage card, RSVP on the invitation card, ticking a task. Each one updates +// the stored snapshot first so every widget redraws at once, then makes the +// JMAP call, then refreshes from the server; a failed call is undone by that +// refresh. + +import { jmapClient } from '../api/jmap-client'; +import type { JMAPClient } from '../api/jmap-client'; +import { noteLocalChange, refreshSnapshot, singletonServes } from './build'; +import { markRead, moveTo, openClient, rsvp, setTaskDone } from './jmap'; +import { loadLocal, saveLocal } from './local-state'; +import { updateAllWidgets } from './render'; +import { emptySnapshot, loadSnapshot, saveSnapshot, type MailItem, type WidgetSnapshot } from './snapshot'; + +async function clientFor(registryAccountId: string | null | undefined): Promise { + if (!registryAccountId) return null; + if (singletonServes(registryAccountId)) return jmapClient; + return openClient(registryAccountId); +} + +async function applyLocally(mutate: (s: WidgetSnapshot) => void): Promise { + noteLocalChange(); + const s = (await loadSnapshot()) ?? emptySnapshot(); + mutate(s); + await saveSnapshot(s); + await updateAllWidgets(s); + return s; +} + +function withoutMessage(s: WidgetSnapshot, id: string): void { + const removed = s.mail.inbox.find((m) => m.id === id); + const drop = (list: MailItem[]) => list.filter((m) => m.id !== id); + s.mail.inbox = drop(s.mail.inbox); + s.mail.unified = drop(s.mail.unified); + s.mail.starred = drop(s.mail.starred); + const inbox = s.mail.folders.find((f) => f.role === 'inbox'); + if (inbox && removed) { + inbox.total = Math.max(0, inbox.total - 1); + if (removed.unread) inbox.unread = Math.max(0, inbox.unread - 1); + } +} + +async function afterServerCall(ok: boolean): Promise { + if (!ok) console.warn('[widgets] action was not applied on the server'); + const fresh = await refreshSnapshot({ after: 'change' }); + await updateAllWidgets(fresh); +} + +type Data = Record; +const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); + +export async function handleWidgetAction(name: string, data: Data, widgetId: number): Promise { + switch (name) { + case 'refresh': + await afterServerCall(true); + return; + + case 'triageNext': { + const s = (await loadSnapshot()) ?? emptySnapshot(); + const unread = s.mail.inbox.filter((m) => m.unread); + if (unread.length === 0) return; + const index = unread.findIndex((m) => m.id === data.id); + const next = unread[(index + 1) % unread.length]; + const local = await loadLocal(widgetId); + await saveLocal(widgetId, { ...local, triageId: next.id }); + await updateAllWidgets(s); + return; + } + + case 'archive': + case 'trash': + case 'markRead': { + const id = str(data.id); + if (!id) return; + if (name === 'markRead') { + await applyLocally((s) => { + const hit = s.mail.inbox.find((m) => m.id === id); + const inbox = s.mail.folders.find((f) => f.role === 'inbox'); + if (hit?.unread && inbox) inbox.unread = Math.max(0, inbox.unread - 1); + for (const list of [s.mail.inbox, s.mail.unified, s.mail.starred]) { + for (const m of list) if (m.id === id) m.unread = false; + } + }); + } else { + // The triage card moves on to the next unread message, not back to + // the first one. + const before = (await loadSnapshot()) ?? emptySnapshot(); + const unread = before.mail.inbox.filter((m) => m.unread); + const index = unread.findIndex((m) => m.id === id); + const next = index >= 0 ? unread[index + 1] ?? unread[index - 1] : undefined; + if (typeof data.widgetId === 'number') { + await saveLocal(data.widgetId, { ...(await loadLocal(data.widgetId)), triageId: next?.id ?? null }); + } + await applyLocally((s) => withoutMessage(s, id)); + } + let ok = false; + try { + const client = await clientFor(str(data.accountId)); + if (client) { + ok = name === 'markRead' + ? await markRead(client, id, str(data.jmapAccountId)) + : await moveTo(client, id, name, str(data.jmapAccountId)); + } + } catch (err) { + console.warn(`[widgets] ${name} failed`, err); + } + await afterServerCall(ok); + return; + } + + case 'rsvp': { + const id = str(data.id); + const status = data.status; + if (!id || (status !== 'accepted' && status !== 'tentative' && status !== 'declined')) return; + const before = (await loadSnapshot()) ?? emptySnapshot(); + const invitation = before.calendar.invitations.find((i) => i.id === id); + if (!invitation) return; + await applyLocally((s) => { + s.calendar.invitations = s.calendar.invitations.filter((i) => i.id !== id); + for (const e of s.calendar.events) if (e.serverId === invitation.serverId) e.myStatus = status; + }); + let ok = false; + try { + const client = await clientFor(before.activeAccountId); + if (client) ok = await rsvp(client, invitation.serverId, invitation.participantId, status, invitation.jmapAccountId); + } catch (err) { + console.warn('[widgets] rsvp failed', err); + } + await afterServerCall(ok); + return; + } + + case 'toggleTask': { + const id = str(data.id); + if (!id) return; + const before = (await loadSnapshot()) ?? emptySnapshot(); + const task = before.tasks.items.find((t) => t.id === id); + if (!task) return; + const done = !task.done; + await applyLocally((s) => { + for (const t of s.tasks.items) if (t.id === id) t.done = done; + }); + let ok = false; + try { + const client = await clientFor(before.activeAccountId); + if (client) ok = await setTaskDone(client, task.serverId, done, task.jmapAccountId); + // In the live app the snapshot's tasks come from the calendar store, + // which did not see this change; reload it so the refresh keeps it. + if (ok && before.activeAccountId && singletonServes(before.activeAccountId)) { + const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store'); + await useCalendarStore.getState().fetchTasks(); + } + } catch (err) { + console.warn('[widgets] task toggle failed', err); + } + await afterServerCall(ok); + return; + } + + default: + console.warn(`[widgets] unknown action ${name}`); + } +} diff --git a/src/widgets/build.ts b/src/widgets/build.ts new file mode 100644 index 00000000..29aad5ed --- /dev/null +++ b/src/widgets/build.ts @@ -0,0 +1,484 @@ +// 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. + +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 { + emptySnapshot, + loadSnapshot, + saveSnapshot, + 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 { + 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[], +): 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, + ...(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): 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)) + .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); + 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): 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(); + 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, + ...(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 { + 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 { + 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(); + + const live = 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), + buildTasks(next), + buildFiles(next), + buildScheduled(next), + ]); + next.calendar = calendar; + next.tasks = tasks; + next.files = files; + next.mail.scheduled = scheduled; + next.appDataAt = now; + } + return next; +} + +let inflight: Promise | null = null; +let localChanges = 0; + +/** + * A widget button just edited the stored snapshot. A refresh that started + * before it read the server before the change, so its result is dropped + * rather than saved over the edit. + */ +export function noteLocalChange(): void { + localChanges++; +} + +/** + * 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. + * + * `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 changesAtStart = localChanges; + const previous = (await loadSnapshot()) ?? emptySnapshot(); + try { + const next = await buildSnapshot(previous); + if (localChanges !== changesAtStart) return (await loadSnapshot()) ?? next; + await saveSnapshot(next); + return next; + } catch (err) { + console.warn('[widgets] refresh failed', err); + return previous; + } finally { + inflight = null; + } + })(); + return inflight; +} diff --git a/src/widgets/catalog.json b/src/widgets/catalog.json new file mode 100644 index 00000000..319c0bfd --- /dev/null +++ b/src/widgets/catalog.json @@ -0,0 +1,40 @@ +{ + "$comment": "Home-screen widgets. scripts/sync-android-widgets.mjs turns this into the Android provider classes, provider XML, labels and manifest receivers; run it after editing. cols/rows are launcher cells (Android 2x2 is roughly the iOS small widget, 4x2 medium, 4x4 large).", + "widgets": [ + { "name": "InboxWidget", "label": "Inbox", "description": "Newest messages with sender, time and subject. Taller sizes add avatars and a line of preview.", "cols": 4, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" }, + { "name": "TriageWidget", "label": "Triage", "description": "One unread message at a time with archive, delete and reply buttons.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" }, + { "name": "UnreadCountWidget", "label": "Unread count", "description": "Unread count for the inbox, with the latest senders.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" }, + { "name": "LatestMessageWidget", "label": "Latest message", "description": "Only the newest unread message.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" }, + { "name": "FolderCountsWidget", "label": "Folder counts", "description": "Unread counts for up to four folders.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" }, + { "name": "MailShortcutsWidget", "label": "Shortcuts", "description": "Compose, calendar, contacts and files.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail", "static": true }, + { "name": "AllAccountsWidget", "label": "All accounts", "description": "The unified inbox across every signed-in account.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" }, + { "name": "StarredWidget", "label": "Starred", "description": "Starred messages, newest first.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" }, + { "name": "FavouritePeopleWidget", "label": "Favourite people", "description": "The people you hear from most, with their unread count.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" }, + { "name": "MailSearchWidget", "label": "Search mail", "description": "Search bar with your recent searches.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 1, "board": "mail" }, + { "name": "OutboxWidget", "label": "Scheduled and outbox", "description": "Scheduled messages and anything waiting in the outbox.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" }, + { "name": "TagWidget", "label": "Tag", "description": "Unread count for your busiest tag.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" }, + { "name": "LastDraftWidget", "label": "Last draft", "description": "Back into the draft you left.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" }, + + { "name": "AgendaWidget", "label": "Agenda", "description": "Upcoming events by day with time, colour and place.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 2, "board": "calendar" }, + { "name": "WeekWidget", "label": "This week", "description": "The week at a glance from 8:00 to 18:00.", "cols": 4, "rows": 2, "minCols": 4, "minRows": 2, "board": "calendar" }, + { "name": "MonthWidget", "label": "Month", "description": "Month grid with a dot per calendar and the next two events.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 3, "board": "calendar" }, + { "name": "TodayEventsWidget", "label": "Today", "description": "Today's date with the rest of today and tomorrow.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "calendar" }, + { "name": "NextEventWidget", "label": "Next event", "description": "What's next, when and where.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "calendar" }, + { "name": "DateNextWidget", "label": "Date and next two", "description": "Today's date with the next two events.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "calendar" }, + { "name": "UpNextWidget", "label": "Up next", "description": "Countdown to the next event with join and running-late buttons.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "calendar" }, + + { "name": "InvitationsWidget", "label": "Invitations", "description": "Pending invitations with accept, maybe and decline.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "plans" }, + { "name": "FreeTimeWidget", "label": "Free time", "description": "When you're free this week.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "plans" }, + { "name": "TasksWidget", "label": "Tasks", "description": "Open tasks with tick boxes that work from the home screen.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 2, "board": "plans" }, + { "name": "TaskProgressWidget", "label": "Task progress", "description": "How many tasks are done.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "plans" }, + { "name": "CountdownWidget", "label": "Countdown", "description": "Days until your next all-day event.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "plans" }, + { "name": "BirthdaysWidget", "label": "Birthdays", "description": "Upcoming birthdays from your contacts.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "plans" }, + + { "name": "TodayHubWidget", "label": "Today overview", "description": "Next event, newest unread mail and tasks due, in one widget.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 3, "board": "hubs" }, + { "name": "MailAndNextWidget", "label": "Mail and next event", "description": "Unread mail on one side, the next event on the other.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "hubs" }, + { "name": "RecentFilesWidget", "label": "Recent files", "description": "Recently changed items in Files.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "hubs" }, + { "name": "AttachmentsWidget", "label": "Recent attachments", "description": "The newest attachments from your mail.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "hubs" }, + { "name": "VacationWidget", "label": "Vacation responder", "description": "Vacation responder status with an on/off switch.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "hubs" }, + { "name": "StorageWidget", "label": "Storage", "description": "How much of your mailbox quota is used.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "hubs" } + ] +} diff --git a/src/widgets/clicks.ts b/src/widgets/clicks.ts new file mode 100644 index 00000000..b88f2a6c --- /dev/null +++ b/src/widgets/clicks.ts @@ -0,0 +1,60 @@ +// What a tap on part of a widget does. Either it opens the app on a deep link +// (handled by src/navigation/linking.ts, no JS needed in the widget), or it +// names an action the widget task performs in the background (./actions.ts). + +import { APP_SCHEME } from '../navigation/linking'; +import type { EventItem, MailItem } from './snapshot'; + +export interface WidgetClick { + clickAction?: string; + clickActionData?: Record; +} + +export type WidgetActionName = + | 'archive' + | 'trash' + | 'markRead' + | 'triageNext' + | 'rsvp' + | 'toggleTask' + | 'refresh'; + +export function action(name: WidgetActionName, data: Record = {}): WidgetClick { + return { clickAction: name, clickActionData: data }; +} + +export function open(uri: string): WidgetClick { + return { clickAction: 'OPEN_URI', clickActionData: { uri } }; +} + +const base = `${APP_SCHEME}://`; +const enc = encodeURIComponent; + +function query(params: Record): string { + const parts = Object.entries(params) + .filter(([, v]) => v !== undefined && v !== '') + .map(([k, v]) => `${k}=${enc(v!)}`); + return parts.length ? `?${parts.join('&')}` : ''; +} + +export const links = { + inbox: () => `${base}mail/folder/inbox`, + message: (m: Pick) => + `${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId })}`, + reply: (m: Pick) => + `${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, action: 'reply' })}`, + draft: (m: Pick) => + `${base}mail/draft/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId })}`, + unified: (params: { view?: 'all' | 'unread' | 'starred'; role?: string } = {}) => + `${base}mail/unified${query({ view: params.view, role: params.role })}`, + scheduled: () => `${base}mail/scheduled`, + search: (q = '') => `${base}mail/search${query({ q })}`, + compose: (params: { to?: string; subject?: string; body?: string } = {}) => + `${base}compose${query({ to: params.to, subject: params.subject, body: params.body })}`, + calendar: () => `${base}calendar`, + event: (e: Pick) => + `${base}calendar/event/${enc(e.serverId)}${query({ account: e.jmapAccountId })}`, + contacts: () => `${base}contacts`, + files: () => `${base}files`, + settings: (tab?: string) => `${base}settings${tab ? `/${enc(tab)}` : ''}`, +}; diff --git a/src/widgets/derive.ts b/src/widgets/derive.ts new file mode 100644 index 00000000..71e8682a --- /dev/null +++ b/src/widgets/derive.ts @@ -0,0 +1,209 @@ +// Views that depend on the current time, computed when a widget draws rather +// than when the snapshot was fetched. Pure functions of (snapshot, now), so +// they are unit-tested without the widget runtime. + +import { addDays, startOfDay } from './format'; +import type { EventItem, TaskItem, WidgetSnapshot } from './snapshot'; + +const DAY = 86400000; + +/** Events overlapping [dayStart, dayStart + 1 day), all-day first, then by start. */ +export function eventsOnDay(events: EventItem[], dayStart: number): EventItem[] { + const dayEnd = addDays(dayStart, 1); + return events + .filter((e) => e.start < dayEnd && (e.end > dayStart || (e.end === e.start && e.start >= dayStart))) + .sort((a, b) => (a.allDay === b.allDay ? a.start - b.start : a.allDay ? -1 : 1)); +} + +/** Timed events that have not ended yet, soonest first. */ +export function upcomingTimed(events: EventItem[], now: number): EventItem[] { + return events.filter((e) => !e.allDay && e.end > now).sort((a, b) => a.start - b.start); +} + +/** The next timed event (running or upcoming) and whether it has started. */ +export function nextEvent(events: EventItem[], now: number): { event: EventItem; running: boolean } | null { + const [event] = upcomingTimed(events, now); + if (!event) return null; + return { event, running: event.start <= now }; +} + +export interface AgendaDay { + dayStart: number; + events: EventItem[]; +} + +/** + * Days from today with at least one event still relevant (today drops the + * events that already ended), up to `maxDays` days with events. + */ +export function agendaDays(events: EventItem[], now: number, maxDays: number, horizonDays = 14): AgendaDay[] { + const out: AgendaDay[] = []; + const today = startOfDay(now); + for (let i = 0; i < horizonDays && out.length < maxDays; i++) { + const day = addDays(today, i); + const list = eventsOnDay(events, day).filter((e) => i > 0 || e.allDay || e.end > now); + if (list.length > 0) out.push({ dayStart: day, events: list }); + } + return out; +} + +/** Start of the week containing `ms` for the given first weekday (0 Sun, 1 Mon). */ +export function startOfWeek(ms: number, weekStart: 0 | 1): number { + const day = startOfDay(ms); + const dow = new Date(day).getDay(); + const diff = (dow - weekStart + 7) % 7; + return addDays(day, -diff); +} + +export interface MonthCell { + dayStart: number; + day: number; + inMonth: boolean; + isToday: boolean; + /** Distinct calendar colours with events that day, at most three. */ + dots: string[]; +} + +/** The month containing `now` as whole weeks (5 or 6 rows). */ +export function monthGrid(events: EventItem[], now: number, weekStart: 0 | 1): MonthCell[][] { + const first = new Date(now); + first.setDate(1); + first.setHours(0, 0, 0, 0); + const month = first.getMonth(); + const today = startOfDay(now); + let cursor = startOfWeek(first.getTime(), weekStart); + const weeks: MonthCell[][] = []; + do { + const week: MonthCell[] = []; + for (let i = 0; i < 7; i++) { + const d = new Date(cursor); + const dots: string[] = []; + for (const e of eventsOnDay(events, cursor)) { + if (!dots.includes(e.color)) dots.push(e.color); + if (dots.length === 3) break; + } + week.push({ + dayStart: cursor, + day: d.getDate(), + inMonth: d.getMonth() === month, + isToday: cursor === today, + dots, + }); + cursor = addDays(cursor, 1); + } + weeks.push(week); + } while (new Date(cursor).getMonth() === month); + return weeks; +} + +export interface BusyBlock { + /** Fraction of the visible window, 0..1. */ + from: number; + to: number; + color: string; +} + +/** + * Timed events of one day clipped to [startHour, endHour) as fractions of that + * window - the week and free-time bars. + */ +export function busyBlocks(events: EventItem[], dayStart: number, startHour = 8, endHour = 18): BusyBlock[] { + const winStart = dayStart + startHour * 3600000; + const winEnd = dayStart + endHour * 3600000; + const span = winEnd - winStart; + return eventsOnDay(events, dayStart) + .filter((e) => !e.allDay && e.end > winStart && e.start < winEnd) + .map((e) => ({ + from: (Math.max(e.start, winStart) - winStart) / span, + to: (Math.min(e.end, winEnd) - winStart) / span, + color: e.color, + })); +} + +export interface FreeTime { + /** The next timed event today, if any. */ + next: EventItem | null; + /** True when an event is running right now. */ + busyNow: boolean; + /** Longest free stretch left today inside working hours, if any. */ + longestGap: { from: number; to: number } | null; +} + +export function freeTime(events: EventItem[], now: number, startHour = 8, endHour = 18): FreeTime { + const today = startOfDay(now); + const winEnd = today + endHour * 3600000; + const timed = eventsOnDay(events, today).filter((e) => !e.allDay); + const busyNow = timed.some((e) => e.start <= now && e.end > now); + const next = timed.find((e) => e.start > now) ?? null; + + let cursor = Math.max(now, today + startHour * 3600000); + let longest: { from: number; to: number } | null = null; + for (const e of timed.filter((x) => x.end > cursor).sort((a, b) => a.start - b.start)) { + if (e.start > cursor) { + const gap = { from: cursor, to: Math.min(e.start, winEnd) }; + if (gap.to > gap.from && (!longest || gap.to - gap.from > longest.to - longest.from)) longest = gap; + } + cursor = Math.max(cursor, e.end); + if (cursor >= winEnd) break; + } + if (cursor < winEnd) { + const gap = { from: cursor, to: winEnd }; + if (!longest || gap.to - gap.from > longest.to - longest.from) longest = gap; + } + return { next, busyNow, longestGap: longest }; +} + +export type DueKind = 'overdue' | 'today' | 'tomorrow' | 'later' | 'none'; + +export function dueKind(task: TaskItem, now: number): DueKind { + if (task.due === undefined) return 'none'; + const today = startOfDay(now); + const dueDay = startOfDay(task.due); + if (task.dueHasTime ? task.due < now : dueDay < today) return 'overdue'; + if (dueDay === today) return 'today'; + if (dueDay === addDays(today, 1)) return 'tomorrow'; + return 'later'; +} + +/** Open tasks first (overdue, then by due date, undated last), completed after. */ +export function sortTasks(tasks: TaskItem[], now: number): TaskItem[] { + const rank: Record = { overdue: 0, today: 1, tomorrow: 2, later: 3, none: 4 }; + return [...tasks].sort((a, b) => { + if (a.done !== b.done) return a.done ? 1 : -1; + const ra = rank[dueKind(a, now)]; + const rb = rank[dueKind(b, now)]; + if (ra !== rb) return ra - rb; + return (a.due ?? Number.MAX_SAFE_INTEGER) - (b.due ?? Number.MAX_SAFE_INTEGER); + }); +} + +/** Tasks that count for "today": overdue, due today, or completed today-ish. */ +export function tasksDueNow(tasks: TaskItem[], now: number): TaskItem[] { + return tasks.filter((t) => { + const kind = dueKind(t, now); + return kind === 'overdue' || kind === 'today'; + }); +} + +/** + * The event a countdown should point at: the next all-day or multi-day event + * that starts after today, else the next timed event on a later day. + */ +export function countdownTarget(events: EventItem[], now: number): EventItem | null { + const tomorrow = addDays(startOfDay(now), 1); + const later = events.filter((e) => e.start >= tomorrow).sort((a, b) => a.start - b.start); + return later.find((e) => e.allDay || e.end - e.start >= DAY) ?? later[0] ?? null; +} + +export function daysUntil(ms: number, now: number): number { + return Math.round((startOfDay(ms) - startOfDay(now)) / DAY); +} + +/** Birthdays in the next `days` days, soonest first. */ +export function upcomingBirthdays(snapshot: WidgetSnapshot, now: number, days = 45) { + const today = startOfDay(now); + const until = addDays(today, days); + return snapshot.calendar.birthdays + .filter((b) => b.date >= today && b.date < until) + .sort((a, b) => a.date - b.date); +} diff --git a/src/widgets/format.ts b/src/widgets/format.ts new file mode 100644 index 00000000..54402ebc --- /dev/null +++ b/src/widgets/format.ts @@ -0,0 +1,132 @@ +// Strings and date formatting for widget layouts. Widgets render in a headless +// task with no React tree, so this uses the plain translate() lookup instead +// of the useTranslation hook. Keys live under `widgets.` in locales/rn/*.json +// (added by `npm run i18n:harvest`); each call repeats the English text as +// the fallback so a missing key never shows up raw on the home screen. + +import { isSupportedLocale, translate, type LocaleCode, type MessageParams } from '../i18n'; + +export interface Fmt { + locale: string; + hour12: boolean; + t: (key: string, fallback: string, params?: MessageParams) => string; + /** Clock time in the user's 12h/24h setting. */ + time: (ms: number) => string; + weekdayShort: (ms: number) => string; + weekdayLong: (ms: number) => string; + monthLong: (ms: number) => string; + /** "Sep 28" */ + dayMonth: (ms: number) => string; + /** "Mon, Sep 28" */ + weekdayDayMonth: (ms: number) => string; + /** "Sep 28, 2026" */ + fullDate: (ms: number) => string; + /** Time today, weekday this week, date otherwise - the list's receivedAt style. */ + listDate: (ms: number, now: number) => string; + /** "in 25 min", "in 2 h", "now". */ + relative: (ms: number, now: number) => string; + bytes: (n: number) => string; +} + +function safeFormat(locale: string, options: Intl.DateTimeFormatOptions): (ms: number) => string { + let formatter: Intl.DateTimeFormat | null = null; + try { + formatter = new Intl.DateTimeFormat(locale, options); + } catch { + try { + formatter = new Intl.DateTimeFormat('en', options); + } catch { + formatter = null; + } + } + return (ms: number) => { + const d = new Date(ms); + if (formatter) { + try { + return formatter.format(d); + } catch { + // fall through + } + } + return d.toDateString(); + }; +} + +const pad = (n: number) => String(n).padStart(2, '0'); + +export function startOfDay(ms: number): number { + const d = new Date(ms); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +export function addDays(ms: number, days: number): number { + const d = new Date(ms); + d.setDate(d.getDate() + days); + return d.getTime(); +} + +export function sameDay(a: number, b: number): boolean { + return startOfDay(a) === startOfDay(b); +} + +export function makeFmt(locale: string, hour12: boolean): Fmt { + const lang: LocaleCode = isSupportedLocale(locale) ? locale : 'en'; + const t = (key: string, fallback: string, params?: MessageParams) => + translate(lang, key, fallback, params); + + const weekdayShort = safeFormat(locale, { weekday: 'short' }); + const weekdayLong = safeFormat(locale, { weekday: 'long' }); + const monthLong = safeFormat(locale, { month: 'long' }); + const dayMonth = safeFormat(locale, { month: 'short', day: 'numeric' }); + const weekdayDayMonth = safeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric' }); + const fullDate = safeFormat(locale, { year: 'numeric', month: 'short', day: 'numeric' }); + + const time = (ms: number) => { + const d = new Date(ms); + const h = d.getHours(); + const m = pad(d.getMinutes()); + if (!hour12) return `${pad(h)}:${m}`; + const suffix = h < 12 ? 'AM' : 'PM'; + return `${h % 12 === 0 ? 12 : h % 12}:${m} ${suffix}`; + }; + + return { + locale, + hour12, + t, + time, + weekdayShort, + weekdayLong, + monthLong, + dayMonth, + weekdayDayMonth, + fullDate, + listDate: (ms, now) => { + if (sameDay(ms, now)) return time(ms); + const days = Math.round((startOfDay(now) - startOfDay(ms)) / 86400000); + if (days > 0 && days < 7) return weekdayShort(ms); + return dayMonth(ms); + }, + relative: (ms, now) => { + const minutes = Math.round((ms - now) / 60000); + if (minutes <= 0) return t('widgets.relative.now', 'now'); + if (minutes < 60) return t('widgets.relative.minutes', 'in {count} min', { count: minutes }); + const hours = Math.round(minutes / 60); + if (hours < 24) return t('widgets.relative.hours', 'in {count} h', { count: hours }); + const days = Math.round((startOfDay(ms) - startOfDay(now)) / 86400000); + return t('widgets.relative.days', '{count, plural, one {in # day} other {in # days}}', { count: days }); + }, + bytes: (n) => { + if (!Number.isFinite(n) || n <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let v = n; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; + }, + }; +} diff --git a/src/widgets/icons.ts b/src/widgets/icons.ts new file mode 100644 index 00000000..88ae2455 --- /dev/null +++ b/src/widgets/icons.ts @@ -0,0 +1,100 @@ +// Tabler Icons (MIT), the set the webmail draws since it moved off Lucide. +// Path data is copied from @tabler/icons tabler-nodes-outline.json so the +// widgets use the same glyphs as the web UI. Widgets render them through +// SvgWidget, which rasterises an SVG string. + +const OUTLINE = { + mail: '', + inbox: '', + send: '', + file: '', + fileText: '', + fileSpreadsheet: '', + presentation: '', + photo: '', + folder: '', + calendarClock: '', + calendar: '', + calendarPlain: '', + archive: '', + trash: '', + ban: '', + search: '', + edit: '', + addressBook: '', + server: '', + paperclip: '', + star: '', + message: '', + mapPin: '', + video: '', + clock: '', + check: '', + plus: '', + chevronLeft: '', + chevronRight: '', + users: '', + lock: '', + cake: '', + beach: '', + reply: '', + checklist: '', + upload: '', + refresh: '', + tag: '', +} as const; + +const FILLED = { + star: '', + tag: '', +} as const; + +export type IconName = keyof typeof OUTLINE; +export type FilledIconName = keyof typeof FILLED; + +/** Splits `#rrggbbaa` into an SVG colour plus opacity; rgba() is passed through. */ +function paint(color: string): { value: string; opacity: string } { + const m = /^#([0-9a-f]{6})([0-9a-f]{2})$/i.exec(color); + if (m) return { value: `#${m[1]}`, opacity: (parseInt(m[2], 16) / 255).toFixed(3) }; + return { value: color, opacity: '1' }; +} + +/** Outline Tabler icon as an SVG string (24 x 24 viewBox, stroke 2 like the web UI). */ +export function icon(name: IconName, color: string, strokeWidth = 2): string { + const { value, opacity } = paint(color); + return ( + `` + + `${OUTLINE[name]}` + ); +} + +/** Filled Tabler icon (star, tag) as an SVG string. */ +export function filledIcon(name: FilledIconName, color: string): string { + const { value, opacity } = paint(color); + return ( + `${FILLED[name]}` + ); +} + +/** + * A progress ring (quota, task progress, countdown). `fraction` is clamped to + * 0..1; the arc starts at 12 o'clock like the webmail's quota ring. + */ +export function ring(fraction: number, track: string, fill: string, strokeWidth = 6): string { + const f = Math.max(0, Math.min(1, Number.isFinite(fraction) ? fraction : 0)); + const r = 32 - strokeWidth / 2; + const c = 2 * Math.PI * r; + const t = paint(track); + const p = paint(fill); + const arc = f > 0 + ? `` + : ''; + return ( + `` + + `` + + `${arc}` + ); +} diff --git a/src/widgets/jmap.ts b/src/widgets/jmap.ts new file mode 100644 index 00000000..1891802c --- /dev/null +++ b/src/widgets/jmap.ts @@ -0,0 +1,450 @@ +// JMAP access for the widgets. Every call goes through a JMAPClient instance +// of its own, never the app's `jmapClient` singleton: the widget task can run +// inside the live React Native instance, where re-binding the singleton would +// send the UI's requests with another account's credentials. The api/* +// helpers are all bound to the singleton, so the few calls the widgets need +// are written out here. + +import { AuthenticationError, JMAPClient, NetworkError } from '../api/jmap-client'; +import { CAPABILITIES, type Attachment, type Email, type Mailbox } from '../api/types'; +import { realAttachments } from '../lib/list-attachments'; +import { findArchiveMailbox, findJunkMailbox, findTrashMailbox } from '../lib/mailbox-tree'; +import { generateEmailAvatarColor, getEmailInitials } from '../lib/avatar-utils'; +import { normalizeHex } from './theme'; +import type { AttachmentGroup, FolderCount, MailItem, Person, QuotaState, VacationState } from './snapshot'; + +type Call = [string, Record, string]; + +const LIST_PROPERTIES = [ + 'id', 'threadId', 'mailboxIds', 'keywords', 'receivedAt', 'from', 'subject', 'preview', 'hasAttachment', +]; +const MAILBOX_PROPERTIES = ['id', 'name', 'role', 'parentId', 'unreadEmails', 'totalEmails', 'sortOrder']; +const NEWEST_FIRST = [{ property: 'receivedAt', isAscending: false }]; + +/** A client for one signed-in account, or null when it has no stored credentials. */ +export async function openClient(registryAccountId: string): Promise { + const client = new JMAPClient(); + try { + return (await client.loadAccount(registryAccountId)) ? client : null; + } catch (err) { + // A revoked password must not log the user out from a widget: the app + // handles that when it next opens. Offline is expected; skip quietly. + if (err instanceof AuthenticationError || err instanceof NetworkError) return null; + throw err; + } +} + +function responseOf(res: { methodResponses: Array<[string, any, string]> }, callId: string): any { + const hit = res.methodResponses.find((r) => r[2] === callId && r[0] !== 'error'); + return hit ? hit[1] : null; +} + +export function toMailItem( + email: Email, + registryAccountId: string, + opts?: { jmapAccountId?: string; threadSize?: number }, +): MailItem { + const from = email.from?.[0]; + const name = from?.name?.trim() || from?.email || ''; + return { + id: email.id, + threadId: email.threadId, + accountId: registryAccountId, + ...(opts?.jmapAccountId ? { jmapAccountId: opts.jmapAccountId } : {}), + fromName: name, + fromEmail: from?.email ?? '', + initials: getEmailInitials(from?.name ?? '', from?.email) || '?', + color: normalizeHex(generateEmailAvatarColor(from?.name ?? '', from?.email)), + subject: email.subject ?? '', + preview: (email.preview ?? '').replace(/\s+/g, ' ').trim(), + receivedAt: Date.parse(email.receivedAt) || 0, + unread: !email.keywords?.$seen, + starred: !!email.keywords?.$flagged, + hasAttachment: !!email.hasAttachment, + threadSize: Math.max(1, opts?.threadSize ?? 1), + }; +} + +const ROLE_ORDER: FolderCount['role'][] = ['inbox', 'drafts', 'scheduled', 'sent', 'archive', 'junk', 'trash']; + +export function folderCounts(mailboxes: Mailbox[]): FolderCount[] { + const out: FolderCount[] = []; + for (const role of ROLE_ORDER) { + const box = role === 'junk' + ? findJunkMailbox(mailboxes) + : role === 'trash' + ? findTrashMailbox(mailboxes) + : role === 'archive' + ? findArchiveMailbox(mailboxes) + : mailboxes.find((m) => m.role === role); + if (box) out.push({ role, name: box.name, unread: box.unreadEmails ?? 0, total: box.totalEmails ?? 0 }); + } + return out; +} + +/** The people who wrote most among recent inbox mail, with their unread count. */ +export function rankPeople(emails: Email[], selfEmails: string[], limit = 4): Person[] { + const self = new Set(selfEmails.map((e) => e.toLowerCase())); + const byEmail = new Map(); + for (const email of emails) { + const from = email.from?.[0]; + const address = from?.email?.toLowerCase(); + if (!address || self.has(address) || /(^|[.+-])(no-?reply|notifications?|mailer-daemon)@/.test(address)) continue; + const entry = byEmail.get(address) ?? { + name: from?.name?.trim() || address, + email: address, + initials: getEmailInitials(from?.name ?? '', address) || '?', + color: normalizeHex(generateEmailAvatarColor(from?.name ?? '', address)), + unread: 0, + lastAt: 0, + count: 0, + }; + entry.count++; + if (!email.keywords?.$seen) entry.unread++; + entry.lastAt = Math.max(entry.lastAt, Date.parse(email.receivedAt) || 0); + byEmail.set(address, entry); + } + return [...byEmail.values()] + .sort((a, b) => b.count - a.count || b.lastAt - a.lastAt) + .slice(0, limit) + .map(({ count: _count, ...person }) => person); +} + +export interface MailSection { + mailboxes: Mailbox[]; + folders: FolderCount[]; + inbox: MailItem[]; + starred: MailItem[]; + starredCount: number; + drafts: MailItem[]; + draftCount: number; + favourites: Person[]; + attachments: AttachmentGroup[]; + tagCounts: Map; + quota: QuotaState | null; + vacation: VacationState | null; + selfEmails: string[]; +} + +/** + * Everything mail-related the widgets show for one account, in three + * requests: mailboxes, then the lists (inbox, starred, drafts, attachments, + * sender ranking, tag counts), then quota and vacation where supported. + */ +export async function fetchMailSection( + client: JMAPClient, + registryAccountId: string, + tagKeywords: string[], +): Promise { + const accountId = client.accountId; + + const boxesRes = await client.request([ + ['Mailbox/get', { accountId, properties: MAILBOX_PROPERTIES }, 'm'], + ['Identity/get', { accountId, properties: ['email'] }, 'i'], + ], [CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION]); + const mailboxes = (responseOf(boxesRes, 'm')?.list ?? []) as Mailbox[]; + const selfEmails = [ + client.username ?? '', + ...((responseOf(boxesRes, 'i')?.list ?? []) as Array<{ email?: string }>).map((i) => i.email ?? ''), + ].filter(Boolean); + + const inbox = mailboxes.find((m) => m.role === 'inbox'); + const drafts = mailboxes.find((m) => m.role === 'drafts'); + const excluded = [findTrashMailbox(mailboxes), findJunkMailbox(mailboxes)] + .filter((m): m is Mailbox => !!m) + .map((m) => m.id); + + const calls: Call[] = []; + const getList = (queryId: string, getId: string, properties = LIST_PROPERTIES) => { + calls.push(['Email/get', { + accountId, + '#ids': { resultOf: queryId, name: 'Email/query', path: '/ids' }, + properties, + }, getId]); + }; + if (inbox) { + calls.push(['Email/query', { + accountId, filter: { inMailbox: inbox.id }, sort: NEWEST_FIRST, collapseThreads: true, limit: 12, + }, 'qi']); + getList('qi', 'gi'); + calls.push(['Thread/get', { + accountId, '#ids': { resultOf: 'gi', name: 'Email/get', path: '/list/*/threadId' }, + }, 'ti']); + calls.push(['Email/query', { + accountId, filter: { inMailbox: inbox.id }, sort: NEWEST_FIRST, limit: 60, + }, 'qp']); + getList('qp', 'gp', ['id', 'from', 'keywords', 'receivedAt']); + calls.push(['Email/query', { + accountId, + filter: { operator: 'AND', conditions: [{ inMailbox: inbox.id }, { hasAttachment: true }] }, + // Read receipts and invitations also count as attachments to the + // server; ask for a few more and keep the ones with real files. + sort: NEWEST_FIRST, collapseThreads: true, limit: 10, + }, 'qa']); + calls.push(['Email/get', { + accountId, + '#ids': { resultOf: 'qa', name: 'Email/query', path: '/ids' }, + properties: ['id', 'from', 'receivedAt', 'attachments'], + bodyProperties: ['name', 'type', 'disposition', 'cid', 'size'], + }, 'ga']); + } + calls.push(['Email/query', { + accountId, + filter: excluded.length + ? { operator: 'AND', conditions: [{ hasKeyword: '$flagged' }, { inMailboxOtherThan: excluded }] } + : { hasKeyword: '$flagged' }, + sort: NEWEST_FIRST, collapseThreads: true, limit: 6, calculateTotal: true, + }, 'qs']); + getList('qs', 'gs'); + if (drafts) { + calls.push(['Email/query', { + accountId, filter: { inMailbox: drafts.id }, sort: NEWEST_FIRST, limit: 1, calculateTotal: true, + }, 'qd']); + getList('qd', 'gd'); + } + const listRes = await client.request(calls); + + const threadSizes = new Map( + ((responseOf(listRes, 'ti')?.list ?? []) as Array<{ id: string; emailIds: string[] }>) + .map((t) => [t.id, t.emailIds?.length ?? 1]), + ); + const inboxEmails = (responseOf(listRes, 'gi')?.list ?? []) as Email[]; + const inboxItems = inboxEmails + .map((e) => toMailItem(e, registryAccountId, { threadSize: threadSizes.get(e.threadId) })) + .sort((a, b) => b.receivedAt - a.receivedAt); + const starred = ((responseOf(listRes, 'gs')?.list ?? []) as Email[]) + .map((e) => toMailItem(e, registryAccountId)) + .sort((a, b) => b.receivedAt - a.receivedAt); + const draftItems = ((responseOf(listRes, 'gd')?.list ?? []) as Email[]).map((e) => toMailItem(e, registryAccountId)); + const attachments: AttachmentGroup[] = ((responseOf(listRes, 'ga')?.list ?? []) as Array; + }>) + .map((e) => ({ + emailId: e.id, + accountId: registryAccountId, + fromName: e.from?.[0]?.name || e.from?.[0]?.email || '', + receivedAt: Date.parse(e.receivedAt) || 0, + files: realAttachments(e.attachments as Attachment[] | undefined) + .map((a) => ({ name: a.name ?? '', type: a.type || 'application/octet-stream' })), + })) + .filter((g) => g.files.length > 0) + .sort((a, b) => b.receivedAt - a.receivedAt) + .slice(0, 3); + + const tagCounts = await fetchTagCounts(client, tagKeywords, excluded, registryAccountId); + const { quota, vacation } = await fetchAccountStatus(client); + + return { + mailboxes, + folders: folderCounts(mailboxes), + inbox: inboxItems, + starred, + starredCount: responseOf(listRes, 'qs')?.total ?? starred.length, + drafts: draftItems, + draftCount: responseOf(listRes, 'qd')?.total ?? draftItems.length, + favourites: rankPeople((responseOf(listRes, 'gp')?.list ?? []) as Email[], selfEmails), + attachments, + tagCounts, + quota, + vacation, + selfEmails, + }; +} + +async function fetchTagCounts( + client: JMAPClient, + keywords: string[], + excluded: string[], + registryAccountId: string, +): Promise { + const out: MailSection['tagCounts'] = new Map(); + const wanted = keywords.slice(0, 8); + if (wanted.length === 0) return out; + const accountId = client.accountId; + const base = (keyword: string) => + excluded.length ? [{ hasKeyword: keyword }, { inMailboxOtherThan: excluded }] : [{ hasKeyword: keyword }]; + const calls: Call[] = []; + wanted.forEach((keyword, i) => { + calls.push(['Email/query', { + accountId, + filter: { operator: 'AND', conditions: base(keyword) }, + sort: NEWEST_FIRST, limit: 1, calculateTotal: true, + }, `t${i}`]); + calls.push(['Email/get', { + accountId, + '#ids': { resultOf: `t${i}`, name: 'Email/query', path: '/ids' }, + properties: LIST_PROPERTIES, + }, `l${i}`]); + calls.push(['Email/query', { + accountId, + filter: { operator: 'AND', conditions: [...base(keyword), { notKeyword: '$seen' }] }, + limit: 0, calculateTotal: true, + }, `u${i}`]); + }); + const max = Math.max(3, client.getMaxCallsInRequest() - (client.getMaxCallsInRequest() % 3)); + for (let start = 0; start < calls.length; start += max) { + const res = await client.request(calls.slice(start, start + max)); + wanted.forEach((keyword, i) => { + const total = responseOf(res, `t${i}`)?.total; + if (total === undefined) return; + const latest = (responseOf(res, `l${i}`)?.list ?? [])[0] as Email | undefined; + out.set(keyword, { + total, + unread: responseOf(res, `u${i}`)?.total ?? 0, + latest: latest ? toMailItem(latest, registryAccountId) : undefined, + }); + }); + } + return out; +} + +async function fetchAccountStatus(client: JMAPClient): Promise<{ quota: QuotaState | null; vacation: VacationState | null }> { + const accountId = client.accountId; + const calls: Call[] = []; + const using: string[] = [CAPABILITIES.CORE]; + if (client.hasAccountCapability(CAPABILITIES.QUOTA)) { + calls.push(['Quota/get', { accountId }, 'q']); + using.push(CAPABILITIES.QUOTA); + } + if (client.hasAccountCapability(CAPABILITIES.VACATION)) { + calls.push(['VacationResponse/get', { accountId, ids: ['singleton'] }, 'v']); + using.push(CAPABILITIES.MAIL, CAPABILITIES.VACATION); + } + if (calls.length === 0) return { quota: null, vacation: null }; + try { + const res = await client.request(calls, using); + const quotas = (responseOf(res, 'q')?.list ?? []) as Array<{ + resourceType?: string; scope?: string; used?: number; hardLimit?: number; limit?: number; types?: string[]; + }>; + const coversMail = (q: (typeof quotas)[number]) => !q.types?.length || q.types.some((t) => t === 'Email' || t === 'Mail'); + const mailQuota = quotas.find((q) => q.resourceType === 'octets' && coversMail(q)) + ?? quotas.find((q) => q.resourceType === 'mail' || q.scope === 'mail'); + const limit = mailQuota ? mailQuota.hardLimit ?? mailQuota.limit ?? 0 : 0; + const v = (responseOf(res, 'v')?.list ?? [])[0] as + | { isEnabled?: boolean; fromDate?: string | null; toDate?: string | null; subject?: string | null } + | undefined; + return { + quota: mailQuota && limit > 0 ? { used: mailQuota.used ?? 0, limit } : null, + vacation: v + ? { + enabled: !!v.isEnabled, + ...(v.fromDate ? { from: Date.parse(v.fromDate) } : {}), + ...(v.toDate ? { to: Date.parse(v.toDate) } : {}), + ...(v.subject ? { subject: v.subject } : {}), + } + : null, + }; + } catch { + return { quota: null, vacation: null }; + } +} + +/** Newest inbox messages of one account, for the all-accounts widget. */ +export async function fetchInboxPreview(client: JMAPClient, registryAccountId: string, limit = 5): Promise<{ + items: MailItem[]; + unread: number; +}> { + const accountId = client.accountId; + const boxes = await client.request([ + ['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'mq'], + ['Mailbox/get', { + accountId, + '#ids': { resultOf: 'mq', name: 'Mailbox/query', path: '/ids' }, + properties: MAILBOX_PROPERTIES, + }, 'mg'], + ]); + const inbox = (responseOf(boxes, 'mg')?.list ?? [])[0] as Mailbox | undefined; + if (!inbox) return { items: [], unread: 0 }; + const res = await client.request([ + ['Email/query', { accountId, filter: { inMailbox: inbox.id }, sort: NEWEST_FIRST, collapseThreads: true, limit }, 'q'], + ['Email/get', { accountId, '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, properties: LIST_PROPERTIES }, 'g'], + ]); + return { + items: ((responseOf(res, 'g')?.list ?? []) as Email[]).map((e) => toMailItem(e, registryAccountId)), + unread: inbox.unreadEmails ?? 0, + }; +} + +// ── Actions ────────────────────────────────────────────────────────────── + +function emailAccount(client: JMAPClient, jmapAccountId?: string): string { + return jmapAccountId ?? client.accountId; +} + +async function setEmail(client: JMAPClient, accountId: string, update: Record>): Promise { + const res = await client.request([['Email/set', { accountId, update }, 's']]); + const body = responseOf(res, 's'); + return !!body && !body.notUpdated; +} + +export async function markRead(client: JMAPClient, emailId: string, jmapAccountId?: string): Promise { + return setEmail(client, emailAccount(client, jmapAccountId), { [emailId]: { 'keywords/$seen': true } }); +} + +/** + * Move a message out of its mailboxes into the archive (or trash). Reads the + * message's current mailboxes first so the patch only drops those. + */ +export async function moveTo( + client: JMAPClient, + emailId: string, + target: 'archive' | 'trash', + jmapAccountId?: string, +): Promise { + const accountId = emailAccount(client, jmapAccountId); + const res = await client.request([ + ['Mailbox/get', { accountId, properties: MAILBOX_PROPERTIES }, 'm'], + ['Email/get', { accountId, ids: [emailId], properties: ['id', 'mailboxIds'] }, 'e'], + ]); + const mailboxes = (responseOf(res, 'm')?.list ?? []) as Mailbox[]; + const email = (responseOf(res, 'e')?.list ?? [])[0] as Pick | undefined; + const destination = target === 'archive' ? findArchiveMailbox(mailboxes) : findTrashMailbox(mailboxes); + if (!email || !destination) return false; + const patch: Record = { [`mailboxIds/${destination.id}`]: true }; + for (const id of Object.keys(email.mailboxIds ?? {})) { + if (id !== destination.id) patch[`mailboxIds/${id}`] = null; + } + if (target === 'trash') patch['keywords/$seen'] = true; + return setEmail(client, accountId, { [emailId]: patch }); +} + +export async function rsvp( + client: JMAPClient, + eventId: string, + participantId: string, + status: 'accepted' | 'tentative' | 'declined', + jmapAccountId?: string, +): Promise { + const accountId = jmapAccountId ?? client.getPrimaryAccountId(CAPABILITIES.CALENDARS); + const res = await client.request([ + ['CalendarEvent/set', { + accountId, + update: { [eventId]: { [`participants/${participantId}/participationStatus`]: status } }, + sendSchedulingMessages: true, + }, 's'], + ], [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]); + const body = responseOf(res, 's'); + return !!body && !body.notUpdated; +} + +/** Same flip as calendar-store's toggleTaskComplete; progressUpdated is left to the server. */ +export async function setTaskDone( + client: JMAPClient, + taskId: string, + done: boolean, + jmapAccountId?: string, +): Promise { + const accountId = jmapAccountId ?? client.getPrimaryAccountId(CAPABILITIES.CALENDARS); + const res = await client.request([ + ['CalendarEvent/set', { + accountId, + update: { + [taskId]: done + ? { progress: 'completed', percentComplete: 100 } + : { progress: 'needs-action', percentComplete: 0 }, + }, + }, 's'], + ], [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]); + const body = responseOf(res, 's'); + return !!body && !body.notUpdated; +} diff --git a/src/widgets/layouts/calendar.tsx b/src/widgets/layouts/calendar.tsx new file mode 100644 index 00000000..ced4a0fe --- /dev/null +++ b/src/widgets/layouts/calendar.tsx @@ -0,0 +1,1061 @@ +// Board "Calendar": the agenda, this week, the month grid, today, the next +// event, the date with the next two events and the up-next countdown. They +// copy the webmail's agenda, week and month views as the mockup drew them. +// +// RemoteViews have no absolute positioning, so everything that sits at a +// computed place (week blocks, the now line) is built from spacers whose dp +// heights come from the widget size the launcher reports. + +import React from 'react'; +import { FlexWidget, OverlapWidget, SvgWidget, TextWidget, type ColorProp } from 'react-native-android-widget'; +import { links, open } from '../clicks'; +import { + agendaDays, + busyBlocks, + daysUntil, + eventsOnDay, + monthGrid, + nextEvent, + startOfWeek, + type AgendaDay, + type BusyBlock, + type MonthCell, +} from '../derive'; +import { addDays, sameDay, startOfDay, type Fmt } from '../format'; +import { ring, type IconName } from '../icons'; +import { AGENDA_ROW_HEIGHT, AgendaRow, DAY_BAND_HEIGHT, DayBand } from '../parts'; +import { Avatar, Button, Dot, EventChip, Fab, Header, Icon, Placeholder, Spacer, Surface, Txt } from '../primitives'; +import type { EventItem, WidgetSnapshot } from '../snapshot'; +import { chipBackground, normalizeHex, type WidgetPalette } from '../theme'; +import type { Layout } from './types'; + +/** Surface's 1dp frame; Android adds a border to the padding, so it eats space. */ +const FRAME = 1; +const HOUR = 3600000; +const GRID_START_HOUR = 8; +const GRID_END_HOUR = 18; + +// --------------------------------------------------------------------------- +// Shared helpers + +function unsupportedText(f: Fmt): string { + return f.t('widgets.calendar.unsupported', "Calendar isn't available on this server"); +} + +function nothingText(f: Fmt): string { + return f.t('widgets.calendar.nothing_planned', 'Nothing on the calendar'); +} + +function Unsupported({ p, f }: { p: WidgetPalette; f: Fmt }) { + return ( + + + + ); +} + +function NothingPlanned({ p, f }: { p: WidgetPalette; f: Fmt }) { + return ( + + + + ); +} + +/** Appends an alpha byte to a `#rrggbb` palette colour; other forms pass through. */ +function withAlpha(color: ColorProp, alpha: string): ColorProp { + return /^#[0-9a-f]{6}$/i.test(color) ? (`${color}${alpha}` as ColorProp) : color; +} + +function upper(text: string, locale: string): string { + try { + return text.toLocaleUpperCase(locale); + } catch { + return text.toUpperCase(); + } +} + +function capitalize(text: string, locale: string): string { + if (!text) return text; + return upper(text.charAt(0), locale) + text.slice(1); +} + +function eventTitle(f: Fmt, e: EventItem): string { + return e.title || f.t('widgets.calendar.untitled', '(no title)'); +} + +function timeRange(f: Fmt, e: EventItem): string { + return `${f.time(e.start)} – ${f.time(e.end)}`; +} + +/** Nothing for today, the weekday for the coming six days, a date after that. */ +function dayPrefix(f: Fmt, ms: number, now: number): string { + const diff = daysUntil(ms, now); + if (diff <= 0) return ''; + return diff < 7 ? f.weekdayShort(ms) : f.dayMonth(ms); +} + +/** "Tue 09:30 – 10:00" for another day, the bare range for today. */ +function whenRange(f: Fmt, e: EventItem, now: number): string { + const prefix = dayPrefix(f, e.start, now); + return prefix ? `${prefix} ${timeRange(f, e)}` : timeRange(f, e); +} + +/** How the month view's chips label an event: "09:30", "Tue 10:00", "All day". */ +function chipTime(f: Fmt, e: EventItem, now: number): string { + const today = startOfDay(now); + const prefix = dayPrefix(f, Math.max(e.start, today), now); + if (e.allDay) return prefix || f.t('widgets.calendar.all_day', 'All day'); + if (e.start < today) return f.t('widgets.calendar.now', 'Now'); + return prefix ? `${prefix} ${f.time(e.start)}` : f.time(e.start); +} + +/** "Today", "Tomorrow", the weekday this week, else weekday and date. */ +function dayLabel(f: Fmt, dayStart: number, now: number): string { + const diff = daysUntil(dayStart, now); + if (diff === 0) return f.t('widgets.calendar.today', 'Today'); + if (diff === 1) return f.t('widgets.calendar.tomorrow', 'Tomorrow'); + return diff > 1 && diff < 7 ? f.weekdayLong(dayStart) : f.weekdayDayMonth(dayStart); +} + +/** + * Events still ahead or running, soonest first, all-day events leading their + * day. An all-day event that began before today is not "next" any more, so a + * week-long trip does not hold the top spot all week. + */ +function upcoming(events: EventItem[], now: number): EventItem[] { + const today = startOfDay(now); + const day = (e: EventItem) => startOfDay(Math.max(e.start, today)); + return events + .filter((e) => (e.allDay ? e.start >= today : e.end > now)) + .sort((a, b) => day(a) - day(b) || (a.allDay === b.allDay ? a.start - b.start : a.allDay ? -1 : 1)); +} + +/** Video link or place, as the agenda row shows it. */ +function placeOf(f: Fmt, e: EventItem): { icon: IconName; text: string } | null { + if (e.videoUrl) return { icon: 'video', text: e.videoName || f.t('widgets.calendar.video_call', 'Video call') }; + if (e.location) return { icon: 'mapPin', text: e.location }; + return null; +} + +/** The signed-in addresses, where an account label is one. */ +function selfAddresses(s: WidgetSnapshot): Set { + return new Set( + s.accounts.map((a) => a.label.trim().toLowerCase()).filter((label) => label.includes('@')), + ); +} + +function monthYear(f: Fmt, ms: number): string { + try { + return new Intl.DateTimeFormat(f.locale, { month: 'long', year: 'numeric' }).format(new Date(ms)); + } catch { + return `${f.monthLong(ms)} ${new Date(ms).getFullYear()}`; + } +} + +// --------------------------------------------------------------------------- +// Agenda (4x4, down to 3x2) + +const AGENDA_FOOTER_HEIGHT = 30; + +interface AgendaPlan { + sections: AgendaDay[]; + /** The first day with rows that did not fit, summarised in the footer. */ + hidden: AgendaDay | null; +} + +function planAgenda(days: AgendaDay[], room: number): AgendaPlan { + const full = days.reduce((h, d) => h + DAY_BAND_HEIGHT + d.events.length * AGENDA_ROW_HEIGHT, 0); + const budget = full <= room ? room : room - AGENDA_FOOTER_HEIGHT; + const sections: AgendaDay[] = []; + let used = 0; + for (const day of days) { + const fit = Math.floor((budget - used - DAY_BAND_HEIGHT) / AGENDA_ROW_HEIGHT); + const take = Math.max(0, Math.min(day.events.length, fit)); + if (take > 0) { + sections.push({ dayStart: day.dayStart, events: day.events.slice(0, take) }); + used += DAY_BAND_HEIGHT + take * AGENDA_ROW_HEIGHT; + } + if (take < day.events.length) { + return { sections, hidden: { dayStart: day.dayStart, events: day.events.slice(take) } }; + } + } + return { sections, hidden: null }; +} + +export const AgendaLayout: Layout = ({ s, p, f, now, height }) => { + if (!s.calendar.supported) return ; + const compact = height < 220; + const headerHeight = compact ? 38 : 44; + const header = ( +
} + /> + ); + const days = agendaDays(s.calendar.events, now, 14); + if (days.length === 0) { + return ( + + {header} + + + ); + } + // Header bar plus its 1dp rule. + const room = height - 2 * FRAME - headerHeight - 1; + const { sections, hidden } = planAgenda(days, room); + const rows: React.JSX.Element[] = []; + sections.forEach((day, d) => { + rows.push(); + day.events.forEach((e, i) => { + const last = !hidden && d === sections.length - 1 && i === day.events.length - 1; + rows.push(); + }); + }); + return ( + + {header} + {rows} + {hidden ? ( + + eventTitle(f, e)).join(', '), + })} + color={p.muted} + size={13} + /> + + ) : null} + + ); +}; + +// --------------------------------------------------------------------------- +// This week (4x2) + +const WEEK_PAD = 10; +const WEEK_LABEL_HEIGHT = 16; +const WEEK_DAY_SIZE = 24; +const WEEK_STRIP_HEIGHT = 20; +const WEEK_GAP = 4; +const WEEK_BLOCK_MIN = 4; +const WEEK_MAX_LANES = 3; +const WEEK_LANE_GAP = 1; + +interface StripSegment { + span: number; + event: EventItem | null; +} + +/** All-day events across the week; a multi-day event becomes one wide chip. */ +function allDaySegments(events: EventItem[], days: number[]): StripSegment[] { + const segments: StripSegment[] = []; + let previousId: string | null = null; + for (const day of days) { + const list = eventsOnDay(events, day).filter((e) => e.allDay); + const keepId: string | null = previousId; + const event: EventItem | null = list.find((e) => e.id === keepId) ?? list[0] ?? null; + const last = segments[segments.length - 1]; + if (last && (last.event?.id ?? null) === (event?.id ?? null)) last.span += 1; + else segments.push({ span: 1, event }); + previousId = event?.id ?? null; + } + return segments; +} + +interface PlacedBlock { + /** dp from the top of the grid. */ + top: number; + height: number; + color: string; +} + +/** + * Stacks one day's busy blocks into side-by-side lanes, the way the week view + * splits overlapping events. Once the lanes run out, a block starts where the + * lane that frees up first ends. + */ +function laneBlocks(blocks: BusyBlock[], gridHeight: number): PlacedBlock[][] { + const lanes: Array<{ end: number; items: PlacedBlock[] }> = []; + for (const b of [...blocks].sort((x, y) => x.from - y.from || y.to - x.to)) { + let top = b.from * gridHeight; + let lane = lanes.find((l) => l.end <= top + 0.5); + if (!lane && lanes.length < WEEK_MAX_LANES) { + lane = { end: 0, items: [] }; + lanes.push(lane); + } + if (!lane) { + lane = lanes.reduce((a, c) => (c.end < a.end ? c : a)); + top = lane.end; + } + top = Math.max(top, lane.end); + const bottom = Math.min(gridHeight, Math.max(b.to * gridHeight, top + WEEK_BLOCK_MIN)); + if (bottom - top < WEEK_BLOCK_MIN) top = Math.max(lane.end, bottom - WEEK_BLOCK_MIN); + if (bottom - top <= 0) continue; + lane.items.push({ top, height: bottom - top, color: b.color }); + lane.end = bottom; + } + return lanes.map((l) => l.items); +} + +function BlockLanes({ lanes, width, height }: { lanes: PlacedBlock[][]; width: number; height: number }) { + const count = Math.max(1, lanes.length); + const laneWidth = Math.max(1, (width - 4 - (count - 1) * WEEK_LANE_GAP) / count); + const columns: React.JSX.Element[] = []; + lanes.forEach((items, l) => { + if (l > 0) columns.push(); + const parts: React.JSX.Element[] = []; + let cursor = 0; + items.forEach((b, j) => { + if (b.top > cursor) parts.push(); + const color = normalizeHex(b.color); + parts.push( + , + ); + cursor = b.top + b.height; + }); + columns.push( + + {parts} + , + ); + }); + return ( + + {columns} + + ); +} + +/** The red now line with its dot, `top` dp down the grid. */ +function NowLine({ p, top, width, height }: { p: WidgetPalette; top: number; width: number; height: number }) { + const offset = Math.max(0, Math.min(height - 6, top - 3)); + return ( + + + + + + + + ); +} + +export const WeekLayout: Layout = ({ s, p, f, now, width, height }) => { + if (!s.calendar.supported) return ; + const events = s.calendar.events; + const first = startOfWeek(now, s.weekStart); + const days = Array.from({ length: 7 }, (_, i) => addDays(first, i)); + const colWidth = (width - 2 * FRAME - 2 * WEEK_PAD) / 7; + const segments = allDaySegments(events, days); + const strip = segments.some((seg) => seg.event !== null); + const gridHeight = Math.max( + 0, + height - 2 * FRAME - WEEK_PAD - WEEK_LABEL_HEIGHT - 2 - WEEK_DAY_SIZE - WEEK_GAP + - (strip ? WEEK_STRIP_HEIGHT + WEEK_GAP : 0) - 1, + ); + const windowStart = startOfDay(now) + GRID_START_HOUR * HOUR; + const nowFraction = (now - windowStart) / ((GRID_END_HOUR - GRID_START_HOUR) * HOUR); + + const headCells = days.map((day) => { + const isToday = sameDay(day, now); + return ( + + + + {isToday ? ( + + + + ) : ( + + + + )} + + ); + }); + + const stripCells = segments.map((seg, i) => { + const e = seg.event; + const color = e ? normalizeHex(e.color) : null; + return ( + + {e && color ? ( + + + + ) : null} + + ); + }); + + const gridColumns = days.map((day, i) => { + const ruled = i < days.length - 1; + const inner = colWidth - (ruled ? 1 : 0); + const lanes = laneBlocks(busyBlocks(events, day, GRID_START_HOUR, GRID_END_HOUR), gridHeight); + const blocks = ; + const showNow = sameDay(day, now) && nowFraction >= 0 && nowFraction <= 1; + return ( + + {showNow ? ( + + {blocks} + + + ) : blocks} + + ); + }); + + return ( + + {headCells} + + {strip ? ( + {stripCells} + ) : null} + {strip ? : null} + + {gridColumns} + + + ); +}; + +// --------------------------------------------------------------------------- +// Month (4x4) + +const MONTH_PAD = 8; +const MONTH_LABEL_HEIGHT = 18; +const MONTH_GRID_TOP = 4; +const MONTH_CHIP_HEIGHT = 24; +const MONTH_CELL_MIN = 30; +const MONTH_CELL_MAX = 48; +/** Rule above the chips: 6dp margin, 1dp border, 6dp padding. */ +const MONTH_CHIPS_TOP = 13; +const MONTH_CHIPS_BOTTOM = 8; + +function NavButton({ p, iconName }: { p: WidgetPalette; iconName: IconName }) { + return ( + + + + ); +} + +function MonthDay({ + p, + cell, + width, + height, + circle, + showDots, +}: { + p: WidgetPalette; + cell: MonthCell; + width: number; + height: number; + circle: number; + showDots: boolean; +}) { + const color = cell.isToday ? p.primaryFg : cell.inMonth ? p.fg : withAlpha(p.muted, 'b3'); + const dots: React.JSX.Element[] = []; + if (showDots) { + cell.dots.forEach((c, i) => { + if (i > 0) dots.push(); + dots.push(); + }); + } + return ( + + + = 22 ? 14 : 13, fontWeight: cell.isToday ? '700' : '400' }} + /> + + {dots.length > 0 ? : null} + {dots.length > 0 ? {dots} : null} + + ); +} + +export const MonthLayout: Layout = ({ s, p, f, now, width, height }) => { + if (!s.calendar.supported) return ; + const weeks = monthGrid(s.calendar.events, now, s.weekStart); + const next = upcoming(s.calendar.events, now); + const headerHeight = height >= 340 ? 44 : 38; + const cellWidth = (width - 2 * FRAME - 2 * MONTH_PAD) / 7; + const chipsHeight = (n: number) => + n === 0 ? 0 : MONTH_CHIPS_TOP + n * MONTH_CHIP_HEIGHT + (n - 1) * 4 + MONTH_CHIPS_BOTTOM; + const gridRoom = (n: number) => + height - 2 * FRAME - headerHeight - MONTH_LABEL_HEIGHT - MONTH_GRID_TOP - chipsHeight(n); + // Two chips when the weeks still get a comfortable row, else one, else none. + let chips = Math.min(2, next.length); + while (chips > 0 && gridRoom(chips) / weeks.length < MONTH_CELL_MIN) chips -= 1; + const cellHeight = Math.max(18, Math.min(MONTH_CELL_MAX, Math.floor(gridRoom(chips) / weeks.length))); + const circle = Math.min(26, Math.max(16, cellHeight - 12)); + const showDots = cellHeight - circle >= 8; + + const chipRows: React.JSX.Element[] = []; + next.slice(0, chips).forEach((e, i) => { + if (i > 0) chipRows.push(); + chipRows.push( + , + ); + }); + + return ( + + + + + + + + + + + {weeks[0].map((cell) => ( + + + + ))} + + + {weeks.map((week) => ( + + {week.map((cell) => ( + + ))} + + ))} + {chipRows.length > 0 ? ( + + {chipRows} + + ) : null} + + ); +}; + +// --------------------------------------------------------------------------- +// Today (4x2) + +const TODAY_PAD = 14; +const TODAY_CHIP = 24; +const TODAY_GAP = 4; +const TODAY_LINE = 16; +/** 6dp extra space, the "Tomorrow" label and one chip, with their gaps. */ +const TOMORROW_BLOCK = 6 + TODAY_GAP + TODAY_LINE + TODAY_GAP + TODAY_CHIP; + +function chipLabel(f: Fmt, e: EventItem): string { + return e.allDay ? f.t('widgets.calendar.all_day', 'All day') : f.time(e.start); +} + +function TodayColumn({ + s, + p, + f, + now, + height, +}: { + s: WidgetSnapshot; + p: WidgetPalette; + f: Fmt; + now: number; + height: number; +}) { + if (!s.calendar.supported) { + return ( + + + + ); + } + const events = s.calendar.events; + const today = startOfDay(now); + const todayList = eventsOnDay(events, today).filter((e) => e.allDay || e.end > now); + const tomorrowList = eventsOnDay(events, addDays(today, 1)); + + // The "N more this week" line is always reserved; chips share the rest. + const room = height - 2 * FRAME - 2 * TODAY_PAD - TODAY_LINE; + const row = TODAY_CHIP + TODAY_GAP; + let withTomorrow = tomorrowList.length > 0; + let slots = Math.floor((room - (withTomorrow ? TOMORROW_BLOCK : 0) + TODAY_GAP) / row); + if (slots < 1 && withTomorrow && todayList.length > 0) { + withTomorrow = false; + slots = Math.floor((room + TODAY_GAP) / row); + } + const shown = todayList.slice(0, Math.max(1, slots)); + + const shownIds = new Set(shown.map((e) => e.id)); + if (withTomorrow) shownIds.add(tomorrowList[0].id); + const weekEnd = addDays(startOfWeek(now, s.weekStart), 7); + const rest = new Set(); + for (let day = today; day < weekEnd; day = addDays(day, 1)) { + for (const e of eventsOnDay(events, day)) { + if (day === today && !e.allDay && e.end <= now) continue; + if (!shownIds.has(e.id)) rest.add(e.id); + } + } + const more = rest.size; + + if (todayList.length === 0 && tomorrowList.length === 0 && more === 0) { + return ( + + + + ); + } + + const items: React.JSX.Element[] = []; + if (shown.length === 0) { + items.push( + , + ); + } + shown.forEach((e, i) => { + if (i > 0) items.push(); + items.push( + , + ); + }); + if (withTomorrow) { + const e = tomorrowList[0]; + items.push(); + items.push( + , + ); + items.push(); + items.push( + , + ); + } + return ( + + {items} + + {more > 0 ? ( + + ) : null} + + ); +} + +export const TodayEventsLayout: Layout = ({ s, p, f, now, height }) => { + // The date block is ~108dp tall at 40sp; a short 4x2 gets a smaller number + // and, if still tight, loses the month line. + const room = height - 2 * FRAME - 2 * TODAY_PAD; + const big = room >= 108 ? 40 : 32; + const showMonth = room >= 3 * 18 + Math.ceil(big * 1.33); + return ( + + + + + + {showMonth ? : null} + + + + + ); +}; + +// --------------------------------------------------------------------------- +// Next event (2x2) + +export const NextEventLayout: Layout = ({ s, p, f, now, height }) => { + if (!s.calendar.supported) return ; + const next = nextEvent(s.calendar.events, now); + if (!next) return ; + const { event: e, running } = next; + const when = running ? f.t('widgets.calendar.now', 'Now') : capitalize(f.relative(e.start, now), f.locale); + // Status line, gap, one title line and the time range always show; then the + // place, the calendar and a second title line, as long as they fit. + const room = height - 2 * FRAME - 28; + let used = 16 + 6 + 22 + 18; + const place = placeOf(f, e); + const showPlace = !!place && used + 16 <= room; + if (showPlace) used += 16; + const showCalendar = !!e.calendarName && used + 16 + (showPlace ? 2 : 0) <= room; + if (showCalendar) used += 16 + (showPlace ? 2 : 0); + const titleLines = used + 22 <= room ? 2 : 1; + return ( + + + + + + + + + + + {showPlace && place ? ( + + + + + + ) : null} + {showPlace && showCalendar ? : null} + {showCalendar ? ( + + + + + + ) : null} + + ); +}; + +// --------------------------------------------------------------------------- +// Date and next two (2x2) + +const DATE_PAD = 12; +const DATE_CIRCLE = 36; +const DATE_CHIP = 36; + +function TwoLineChip({ f, e, now }: { f: Fmt; e: EventItem; now: number }) { + const color = normalizeHex(e.color); + return ( + + + + + ); +} + +export const DateNextLayout: Layout = ({ s, p, f, now, height }) => { + const room = height - 2 * FRAME - 2 * DATE_PAD - DATE_CIRCLE - 8; + const count = room >= 2 * DATE_CHIP + 4 ? 2 : 1; + const next = s.calendar.supported ? upcoming(s.calendar.events, now).slice(0, count) : []; + const chips: React.JSX.Element[] = []; + next.forEach((e, i) => { + if (i > 0) chips.push(); + chips.push(); + }); + return ( + + + + + + + + + + + + + {chips.length > 0 ? chips : ( + + )} + + ); +}; + +// --------------------------------------------------------------------------- +// Up next (4x2) + +interface Countdown { + value: string; + unit: string; + /** Share of the ring to fill: minutes to go out of an hour. */ + fraction: number; +} + +function countdown(f: Fmt, e: EventItem, now: number, running: boolean): Countdown { + const minutes = Math.round((e.start - now) / 60000); + if (running || minutes <= 0) return { value: f.t('widgets.calendar.now', 'Now'), unit: '', fraction: 0 }; + if (minutes < 60) { + return { + value: String(minutes), + unit: f.t('widgets.calendar.unit_minutes', '{count, plural, one {min} other {min}}', { count: minutes }), + fraction: minutes / 60, + }; + } + const hours = Math.round(minutes / 60); + if (hours < 24) { + return { + value: String(hours), + unit: f.t('widgets.calendar.unit_hours', '{count, plural, one {hour} other {hours}}', { count: hours }), + fraction: 1, + }; + } + const days = daysUntil(e.start, now); + return { + value: String(days), + unit: f.t('widgets.calendar.unit_days', '{count, plural, one {day} other {days}}', { count: days }), + fraction: 1, + }; +} + +function People({ p, e }: { p: WidgetPalette; e: EventItem }) { + const faces = e.participants.slice(0, 3); + return ( + + {faces.map((person, i) => ( + + + + ))} + + + + + + ); +} + +export const UpNextLayout: Layout = ({ s, p, f, now }) => { + if (!s.calendar.supported) return ; + const next = nextEvent(s.calendar.events, now); + if (!next) return ; + const { event: e, running } = next; + const title = eventTitle(f, e); + const left = countdown(f, e, now, running); + const meta = [whenRange(f, e, now), e.calendarName].filter(Boolean).join(' · '); + + const self = selfAddresses(s); + const recipients = Array.from( + new Set(e.participants.map((x) => x.email.trim()).filter((x) => x && !self.has(x.toLowerCase()))), + ); + const late = links.compose({ + to: recipients.length > 0 ? recipients.join(',') : undefined, + subject: f.t('widgets.calendar.running_late_subject', 'Running late: {title}', { title }), + body: f.t('widgets.calendar.running_late_body', "I'm running a few minutes late for {title}.", { title }), + }); + const joinLabel = e.videoName && e.videoName.length <= 12 + ? f.t('widgets.calendar.join_named', 'Join {name}', { name: e.videoName }) + : f.t('widgets.calendar.join', 'Join'); + + return ( + + + + + + 3 ? 13 : 18, fontWeight: '700' }} + /> + {left.unit ? ( + + ) : null} + + + + + + + + + + + + {e.participants.length > 0 ? : null} + {e.participants.length > 0 ? : null} + + + + + {e.videoUrl ? ( +