feat: add android home-screen widgets for mail, calendar, tasks and files

This commit is contained in:
Linus Rath
2026-09-26 18:53:25 +02:00
parent 4052523ba9
commit c165fef588
138 changed files with 8682 additions and 49 deletions
+34 -3
View File
@@ -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<voi
async function openDeepLink(link: DeepLink): Promise<void> {
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.
+227
View File
@@ -66,5 +66,232 @@
</intent-filter>
</service>
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
<!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
</receiver>
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
</receiver>
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
</receiver>
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
</receiver>
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
</receiver>
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
</receiver>
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
</receiver>
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
</receiver>
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
</receiver>
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
</receiver>
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
</receiver>
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
</receiver>
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
</receiver>
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
</receiver>
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
</receiver>
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
</receiver>
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
</receiver>
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
</receiver>
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
</receiver>
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
</receiver>
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
</receiver>
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
</receiver>
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
</receiver>
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
</receiver>
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
</receiver>
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
</receiver>
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
</receiver>
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
</receiver>
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
</receiver>
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
</receiver>
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
</receiver>
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
</receiver>
<!-- END WIDGETS -->
</application>
</manifest>
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<resources>
<string name="widgetprovider_inboxwidget_label">Inbox</string>
<string name="widgetprovider_inboxwidget_description">Newest messages with sender, time and subject. Taller sizes add avatars and a line of preview.</string>
<string name="widgetprovider_triagewidget_label">Triage</string>
<string name="widgetprovider_triagewidget_description">One unread message at a time with archive, delete and reply buttons.</string>
<string name="widgetprovider_unreadcountwidget_label">Unread count</string>
<string name="widgetprovider_unreadcountwidget_description">Unread count for the inbox, with the latest senders.</string>
<string name="widgetprovider_latestmessagewidget_label">Latest message</string>
<string name="widgetprovider_latestmessagewidget_description">Only the newest unread message.</string>
<string name="widgetprovider_foldercountswidget_label">Folder counts</string>
<string name="widgetprovider_foldercountswidget_description">Unread counts for up to four folders.</string>
<string name="widgetprovider_mailshortcutswidget_label">Shortcuts</string>
<string name="widgetprovider_mailshortcutswidget_description">Compose, calendar, contacts and files.</string>
<string name="widgetprovider_allaccountswidget_label">All accounts</string>
<string name="widgetprovider_allaccountswidget_description">The unified inbox across every signed-in account.</string>
<string name="widgetprovider_starredwidget_label">Starred</string>
<string name="widgetprovider_starredwidget_description">Starred messages, newest first.</string>
<string name="widgetprovider_favouritepeoplewidget_label">Favourite people</string>
<string name="widgetprovider_favouritepeoplewidget_description">The people you hear from most, with their unread count.</string>
<string name="widgetprovider_mailsearchwidget_label">Search mail</string>
<string name="widgetprovider_mailsearchwidget_description">Search bar with your recent searches.</string>
<string name="widgetprovider_outboxwidget_label">Scheduled and outbox</string>
<string name="widgetprovider_outboxwidget_description">Scheduled messages and anything waiting in the outbox.</string>
<string name="widgetprovider_tagwidget_label">Tag</string>
<string name="widgetprovider_tagwidget_description">Unread count for your busiest tag.</string>
<string name="widgetprovider_lastdraftwidget_label">Last draft</string>
<string name="widgetprovider_lastdraftwidget_description">Back into the draft you left.</string>
<string name="widgetprovider_agendawidget_label">Agenda</string>
<string name="widgetprovider_agendawidget_description">Upcoming events by day with time, colour and place.</string>
<string name="widgetprovider_weekwidget_label">This week</string>
<string name="widgetprovider_weekwidget_description">The week at a glance from 8:00 to 18:00.</string>
<string name="widgetprovider_monthwidget_label">Month</string>
<string name="widgetprovider_monthwidget_description">Month grid with a dot per calendar and the next two events.</string>
<string name="widgetprovider_todayeventswidget_label">Today</string>
<string name="widgetprovider_todayeventswidget_description">Today\'s date with the rest of today and tomorrow.</string>
<string name="widgetprovider_nexteventwidget_label">Next event</string>
<string name="widgetprovider_nexteventwidget_description">What\'s next, when and where.</string>
<string name="widgetprovider_datenextwidget_label">Date and next two</string>
<string name="widgetprovider_datenextwidget_description">Today\'s date with the next two events.</string>
<string name="widgetprovider_upnextwidget_label">Up next</string>
<string name="widgetprovider_upnextwidget_description">Countdown to the next event with join and running-late buttons.</string>
<string name="widgetprovider_invitationswidget_label">Invitations</string>
<string name="widgetprovider_invitationswidget_description">Pending invitations with accept, maybe and decline.</string>
<string name="widgetprovider_freetimewidget_label">Free time</string>
<string name="widgetprovider_freetimewidget_description">When you\'re free this week.</string>
<string name="widgetprovider_taskswidget_label">Tasks</string>
<string name="widgetprovider_taskswidget_description">Open tasks with tick boxes that work from the home screen.</string>
<string name="widgetprovider_taskprogresswidget_label">Task progress</string>
<string name="widgetprovider_taskprogresswidget_description">How many tasks are done.</string>
<string name="widgetprovider_countdownwidget_label">Countdown</string>
<string name="widgetprovider_countdownwidget_description">Days until your next all-day event.</string>
<string name="widgetprovider_birthdayswidget_label">Birthdays</string>
<string name="widgetprovider_birthdayswidget_description">Upcoming birthdays from your contacts.</string>
<string name="widgetprovider_todayhubwidget_label">Today overview</string>
<string name="widgetprovider_todayhubwidget_description">Next event, newest unread mail and tasks due, in one widget.</string>
<string name="widgetprovider_mailandnextwidget_label">Mail and next event</string>
<string name="widgetprovider_mailandnextwidget_description">Unread mail on one side, the next event on the other.</string>
<string name="widgetprovider_recentfileswidget_label">Recent files</string>
<string name="widgetprovider_recentfileswidget_description">Recently changed items in Files.</string>
<string name="widgetprovider_attachmentswidget_label">Recent attachments</string>
<string name="widgetprovider_attachmentswidget_description">The newest attachments from your mail.</string>
<string name="widgetprovider_vacationwidget_label">Vacation responder</string>
<string name="widgetprovider_vacationwidget_description">Vacation responder status with an on/off switch.</string>
<string name="widgetprovider_storagewidget_label">Storage</string>
<string name="widgetprovider_storagewidget_description">How much of your mailbox quota is used.</string>
</resources>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="250dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="4"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_agendawidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/agendawidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_allaccountswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/allaccountswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_attachmentswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/attachmentswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_birthdayswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/birthdayswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_countdownwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/countdownwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_datenextwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/datenextwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_favouritepeoplewidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/favouritepeoplewidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_foldercountswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/foldercountswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_freetimewidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/freetimewidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_inboxwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/inboxwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_invitationswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/invitationswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_lastdraftwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/lastdraftwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_latestmessagewidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/latestmessagewidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_mailandnextwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/mailandnextwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="40dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_mailsearchwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/mailsearchwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_mailshortcutswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/mailshortcutswidget_preview"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="250dp"
android:minResizeWidth="180dp"
android:minResizeHeight="180dp"
android:targetCellWidth="4"
android:targetCellHeight="4"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_monthwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/monthwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_nexteventwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/nexteventwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_outboxwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/outboxwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_recentfileswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/recentfileswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_starredwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/starredwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_storagewidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/storagewidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_tagwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/tagwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_taskprogresswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/taskprogresswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="250dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="4"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_taskswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/taskswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_todayeventswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/todayeventswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="250dp"
android:minResizeWidth="180dp"
android:minResizeHeight="180dp"
android:targetCellWidth="4"
android:targetCellHeight="4"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_todayhubwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/todayhubwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_triagewidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/triagewidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_unreadcountwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/unreadcountwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_upnextwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/upnextwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_vacationwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/vacationwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="250dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_weekwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/weekwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
+11 -2
View File
@@ -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<typeof pushBackgroundTask>[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
+183
View File
@@ -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}"
}
}
}
+17
View File
@@ -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",
+3 -1
View File
@@ -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",
+193
View File
@@ -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/<WidgetName>.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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').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 `<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="${dp(w.cols)}dp"
android:minHeight="${dp(w.rows)}dp"
android:minResizeWidth="${dp(w.minCols ?? w.cols)}dp"
android:minResizeHeight="${dp(w.minRows ?? w.rows)}dp"
android:targetCellWidth="${w.cols}"
android:targetCellHeight="${w.rows}"
android:resizeMode="horizontal|vertical"
android:description="@string/${resName(w.name)}_description"
android:initialLayout="@layout/rn_widget"${preview}
android:updatePeriodMillis="${w.static ? 0 : UPDATE_PERIOD_MS}"
android:widgetCategory="home_screen">
</appwidget-provider>
`;
}
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) => [
` <string name="${resName(w.name)}_label">${escapeXml(w.label)}</string>`,
` <string name="${resName(w.name)}_description">${escapeXml(w.description)}</string>`,
]);
return `<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<resources>
${lines.join('\n')}
</resources>
`;
}
function manifestBlock(widgets) {
const receivers = widgets.map(
(w) => ` <receiver android:name=".widget.${w.name}" android:exported="false" android:label="@string/${resName(w.name)}_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${PACKAGE}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/${resName(w.name)}"/>
</receiver>`,
);
return ` <!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
${receivers.join('\n')}
<!-- END WIDGETS -->`;
}
function withManifestBlock(manifest, block) {
const begin = manifest.indexOf(' <!-- BEGIN WIDGETS');
const endMarker = '<!-- END WIDGETS -->';
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(' </application>');
if (close === -1) throw new Error('AndroidManifest.xml has no </application>');
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.`);
+50
View File
@@ -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/<id>?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<RootStackParamList['Compose']> | 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,
},
};
}
+47
View File
@@ -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(
+68 -6
View File
@@ -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=<id>` from the webmail's older service worker.
@@ -131,8 +166,13 @@ export function parseDeepLink(url: string): DeepLink | null {
export interface DeepLinkNavigator {
navigation: NavigationContainerRefWithCurrent<RootStackParamList>;
// Resolve a message id to its thread (EmailThread needs both). Returns
// null when the message cannot be loaded.
resolveThreadId: (emailId: string) => Promise<string | null>;
// 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<string | null>;
// 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<boolean>;
openDraft?: (emailId: string, jmapAccountId?: string) => Promise<boolean>;
// Switch to the account a permalink names (`?account=`); resolves false
// when that account is not signed in on this device.
switchAccount?: (accountId: string) => Promise<boolean>;
@@ -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);
+25
View File
@@ -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<PendingMailSearchState>((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);
}
+13
View File
@@ -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<TextInput>(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
<View style={styles.searchInputArea}>
<Search size={16} color={c.textMuted} />
<TextInput
ref={searchInputRef}
style={styles.searchInput}
placeholder={t('email_list.search_placeholder', 'Search mail...')}
placeholderTextColor={c.textMuted}
+3 -36
View File
@@ -38,7 +38,7 @@ import { useLocaleStore } from '../stores/locale-store';
import {
findArchiveMailbox, findTrashMailbox, mailboxAccountId, mailboxesOfAccount, mailboxOfEmail,
} from '../lib/mailbox-tree';
import { pickEmailBody, plainTextBody } from '../lib/email-body';
import { replyComposeParams } from '../lib/reply-compose';
import { singleLine } from '../lib/single-line';
import { buildForwardAsAttachmentPayload } from '../lib/forward-as-attachment';
import { viewerInstance, viewerPages, type ViewerInstance } from '../lib/viewer-pages';
@@ -532,41 +532,8 @@ function EmailViewer({ route, navigation }: Props) {
const navigateCompose = React.useCallback((mode: 'reply' | 'replyAll' | 'forward', target?: Email) => {
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).
+4 -1
View File
@@ -552,8 +552,11 @@ export function spansAccounts(state: Pick<EmailState, 'searchQuery' | 'filters'>
*/
export function spannedAccounts(mailboxes: Mailbox[]): Array<string | undefined> {
const out: Array<string | undefined> = [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);
}
}
+117
View File
@@ -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> = {}): 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']);
});
});
+63
View File
@@ -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],
]);
});
});
+125
View File
@@ -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<string, unknown> = {}) => {
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<string, unknown> }) => unknown };
const checked = (module: Record<string, unknown>) => {
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' });
});
});
+163
View File
@@ -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<JMAPClient | null> {
if (!registryAccountId) return null;
if (singletonServes(registryAccountId)) return jmapClient;
return openClient(registryAccountId);
}
async function applyLocally(mutate: (s: WidgetSnapshot) => void): Promise<WidgetSnapshot> {
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<void> {
if (!ok) console.warn('[widgets] action was not applied on the server');
const fresh = await refreshSnapshot({ after: 'change' });
await updateAllWidgets(fresh);
}
type Data = Record<string, unknown>;
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
export async function handleWidgetAction(name: string, data: Data, widgetId: number): Promise<void> {
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}`);
}
}
+484
View File
@@ -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<string[]> {
try {
const raw = await AsyncStorage.getItem('search-history-storage');
const list = raw ? (JSON.parse(raw) as { state?: { recentSearches?: unknown } }).state?.recentSearches : null;
return Array.isArray(list) ? list.filter((s): s is string => typeof s === 'string').slice(0, 8) : [];
} catch {
return [];
}
}
async function readPendingChanges(accountId: string): Promise<number> {
try {
const raw = await AsyncStorage.getItem(`webmail:outbox:v1:${accountId}`);
const list = raw ? JSON.parse(raw) : null;
return Array.isArray(list) ? list.length : 0;
} catch {
return 0;
}
}
/** True when the app's singleton is signed in to `registryAccountId` (live runtime). */
export function singletonServes(registryAccountId: string): boolean {
if (!jmapClient.isConnected) return false;
const { username, serverUrl } = jmapClient;
return !!username && !!serverUrl && generateAccountId(username, serverUrl) === registryAccountId;
}
function tagColor(color: string): string {
const tags = LIGHT_COLORS.tags;
return (color in tags ? tags[color as keyof typeof tags] : tags.gray).dot;
}
function tagCounts(section: MailSection): TagCount[] {
const { keywords } = useKeywordsStore.getState();
const out: TagCount[] = [];
for (const kw of keywords) {
const counts = section.tagCounts.get(keywordToken(kw.id));
if (!counts || counts.total === 0) continue;
out.push({
keyword: keywordToken(kw.id),
name: kw.label,
color: tagColor(kw.color),
unread: counts.unread,
total: counts.total,
...(counts.latest ? { latestFrom: counts.latest.fromName, latestAt: counts.latest.receivedAt } : {}),
});
}
return out.sort((a, b) => b.unread - a.unread || b.total - a.total);
}
// ── App-backed sections (live runtime only) ──────────────────────────────
function participantAddress(p: { email?: string; calendarAddress?: string; sendTo?: Record<string, string> }): string {
const raw = p.email || p.calendarAddress || p.sendTo?.imip || '';
return raw.replace(/^mailto:/i, '');
}
function toEventItem(
event: CalendarEvent,
calendarsById: Map<string, { name: string; color?: string }>,
selfEmails: string[],
): 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<WidgetSnapshot['calendar']> {
const calendarsSupported = jmapClient.hasAccountCapability(CAPABILITIES.CALENDARS);
if (!calendarsSupported) return { supported: false, events: [], invitations: [], birthdays: await buildBirthdays(now) };
const { useCalendarStore, loadEventsInRange } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
// Always refetch: the persisted list can predate a calendar deleted and
// re-created on the server under the same id, which would put events under
// the old name and colour.
try {
await useCalendarStore.getState().fetchCalendars();
} catch {
// fall back to the cached list
}
const { calendars, hiddenCalendarIds } = useCalendarStore.getState();
const visible = calendars.filter((c) => !hiddenCalendarIds.includes(c.id));
const byId = new Map(calendars.map((c) => [c.id, { name: c.name, color: c.color }]));
const first = new Date(now);
first.setDate(1);
const rangeStart = startOfWeek(first.getTime(), useSettingsStore.getState().calendarFirstDayOfWeek === 0 ? 0 : 1);
const rangeEnd = Math.max(addDays(startOfDay(now), 36), addDays(rangeStart, 42));
let raw: CalendarEvent[];
try {
raw = visible.length === 0
? []
: await loadEventsInRange(calendars, visible.map((c) => c.id), new Date(rangeStart).toISOString(), new Date(rangeEnd).toISOString());
} catch (err) {
console.warn('[widgets] calendar refresh failed', err);
return { ...previous.calendar, birthdays: await buildBirthdays(now) };
}
const live = raw.filter((e) => e.status !== 'cancelled');
const events = live
.map((e) => toEventItem(e, byId, selfEmails))
.filter((e) => e.end >= rangeStart && e.start < rangeEnd)
.sort((a, b) => a.start - b.start);
// One entry per series: answering an invitation answers the whole event.
const invitations: Invitation[] = [];
const seen = new Set<string>();
for (const e of live) {
if (getUserStatus(e, selfEmails) !== 'needs-action') continue;
const participantId = getUserParticipantId(e, selfEmails);
const key = e.originalId || e.id;
if (!participantId || seen.has(key)) continue;
const item = toEventItem(e, byId, selfEmails);
if (item.end < now) continue;
seen.add(key);
invitations.push({ ...item, participantId });
}
return {
supported: true,
events,
invitations: invitations.sort((a, b) => a.start - b.start).slice(0, 10),
birthdays: await buildBirthdays(now),
};
}
async function buildBirthdays(now: number): Promise<Birthday[]> {
const { useContactsStore } = require('../stores/contacts-store') as typeof import('../stores/contacts-store');
// The Contacts tab loads the address book lazily; the widget may be first.
if (jmapClient.isConnected) {
try {
await useContactsStore.getState().fetchContactsIfStale();
} catch {
// use whatever is cached
}
}
const contacts: ContactCard[] = useContactsStore.getState().contacts ?? [];
const today = startOfDay(now);
const out: Birthday[] = [];
for (const contact of contacts) {
const date = getBirthday(contact);
if (!date) continue;
const { month, day } = getDateParts(date);
if (!month || !day) continue;
const thisYear = new Date(new Date(today).getFullYear(), month - 1, day).getTime();
const next = thisYear >= today ? thisYear : new Date(new Date(today).getFullYear() + 1, month - 1, day).getTime();
const name = getContactDisplayName(contact);
const email = Object.values(contact.emails ?? {})[0]?.address;
out.push({
name,
...(email ? { email } : {}),
initials: getEmailInitials(name, email) || '?',
color: normalizeHex(generateEmailAvatarColor(name, email)),
date: next,
});
}
return out.sort((a, b) => a.date - b.date).slice(0, 20);
}
async function buildTasks(previous: WidgetSnapshot): Promise<WidgetSnapshot['tasks']> {
if (!useSettingsStore.getState().enableCalendarTasks) return { supported: false, items: [] };
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
try {
const store = useCalendarStore.getState();
if (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<WidgetSnapshot['files']> {
const files = require('../api/files') as typeof import('../api/files');
if (!files.supportsFiles()) return { supported: false, items: [] };
try {
const nodes = await files.getAllFileNodes();
const items: FileItem[] = nodes
.filter((n) => !!n.name)
.map((n) => ({
id: n.id,
name: n.name,
isFolder: files.isFolder(n),
size: n.size ?? 0,
modified: Date.parse(n.modified || n.created || '') || 0,
type: n.type || '',
}))
.sort((a, b) => b.modified - a.modified)
.slice(0, 6);
return { supported: true, items };
} catch {
return previous.files;
}
}
async function buildScheduled(previous: WidgetSnapshot): Promise<ScheduledItem[]> {
try {
if (!jmapClient.hasDelayedSend()) return [];
const { listScheduledEmails } = require('../api/email') as typeof import('../api/email');
const list = await listScheduledEmails();
return list.slice(0, 6).map((s) => {
const to = s.to?.[0];
return {
id: s.emailSubmissionId,
to: to?.name || to?.email || '',
subject: s.subject ?? '',
sendAt: Date.parse(s.sendAt) || 0,
};
});
} catch {
return previous.mail.scheduled;
}
}
// ── Assembly ─────────────────────────────────────────────────────────────
async function buildSnapshot(previous: WidgetSnapshot): Promise<WidgetSnapshot> {
const now = Date.now();
const { accounts, activeAccountId } = await readRegistry();
if (!activeAccountId) return { ...emptySnapshot(), generatedAt: now };
const settings = useSettingsStore.getState();
if (!settings.hydrated) await settings.hydrate();
const keywordsState = useKeywordsStore.getState();
if (!keywordsState.hydrated) await keywordsState.hydrate();
const { theme, timeFormat, calendarFirstDayOfWeek } = useSettingsStore.getState();
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<WidgetSnapshot> | 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<WidgetSnapshot> {
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;
}
+40
View File
@@ -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" }
]
}
+60
View File
@@ -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<string, unknown>;
}
export type WidgetActionName =
| 'archive'
| 'trash'
| 'markRead'
| 'triageNext'
| 'rsvp'
| 'toggleTask'
| 'refresh';
export function action(name: WidgetActionName, data: Record<string, unknown> = {}): 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, string | undefined>): 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<MailItem, 'id' | 'accountId' | 'jmapAccountId'>) =>
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId })}`,
reply: (m: Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'>) =>
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, action: 'reply' })}`,
draft: (m: Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'>) =>
`${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<EventItem, 'serverId' | 'jmapAccountId'>) =>
`${base}calendar/event/${enc(e.serverId)}${query({ account: e.jmapAccountId })}`,
contacts: () => `${base}contacts`,
files: () => `${base}files`,
settings: (tab?: string) => `${base}settings${tab ? `/${enc(tab)}` : ''}`,
};
+209
View File
@@ -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<DueKind, number> = { 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);
}
+132
View File
@@ -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]}`;
},
};
}
+100
View File
@@ -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: '<path d="M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10"/><path d="M3 7l9 6l9 -6"/>',
inbox: '<path d="M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12"/><path d="M4 13h3l3 3h4l3 -3h3"/>',
send: '<path d="M10 14l11 -11"/><path d="M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5"/>',
file: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/>',
fileText: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/><path d="M9 9l1 0"/><path d="M9 13l6 0"/><path d="M9 17l6 0"/>',
fileSpreadsheet: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/><path d="M8 11h8v7h-8l0 -7"/><path d="M8 15h8"/><path d="M11 11v7"/>',
presentation: '<path d="M3 4l18 0"/><path d="M4 4v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-10"/><path d="M12 16l0 4"/><path d="M9 20l6 0"/><path d="M8 12l3 -3l2 2l3 -3"/>',
photo: '<path d="M15 8h.01"/><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12"/><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"/><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"/>',
folder: '<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2"/>',
calendarClock: '<path d="M10.5 21h-4.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v3"/><path d="M16 3v4"/><path d="M8 3v4"/><path d="M4 11h10"/><path d="M14 18a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"/><path d="M18 16.5v1.5l.5 .5"/>',
calendar: '<path d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12"/><path d="M16 3v4"/><path d="M8 3v4"/><path d="M4 11h16"/><path d="M11 15h1"/><path d="M12 15v3"/>',
calendarPlain: '<path d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12"/><path d="M16 3v4"/><path d="M8 3v4"/><path d="M4 11h16"/>',
archive: '<path d="M3 6a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2"/><path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10"/><path d="M10 12l4 0"/>',
trash: '<path d="M4 7l16 0"/><path d="M10 11l0 6"/><path d="M14 11l0 6"/><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"/><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"/>',
ban: '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M5.7 5.7l12.6 12.6"/>',
search: '<path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/>',
edit: '<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415"/><path d="M16 5l3 3"/>',
addressBook: '<path d="M20 6v12a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2"/><path d="M10 16h6"/><path d="M11 11a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M4 8h3"/><path d="M4 12h3"/><path d="M4 16h3"/>',
server: '<path d="M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2"/><path d="M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2"/><path d="M7 8l0 .01"/><path d="M7 16l0 .01"/><path d="M11 8h6"/><path d="M11 16h6"/>',
paperclip: '<path d="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"/>',
star: '<path d="M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873l-6.158 -3.245"/>',
message: '<path d="M8 9h8"/><path d="M8 13h6"/><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12"/>',
mapPin: '<path d="M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/><path d="M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0"/>',
video: '<path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4"/><path d="M3 8a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2l0 -8"/>',
clock: '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/><path d="M12 7v5l3 3"/>',
check: '<path d="M5 12l5 5l10 -10"/>',
plus: '<path d="M12 5l0 14"/><path d="M5 12l14 0"/>',
chevronLeft: '<path d="M15 6l-6 6l6 6"/>',
chevronRight: '<path d="M9 6l6 6l-6 6"/>',
users: '<path d="M5 7a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"/><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><path d="M21 21v-2a4 4 0 0 0 -3 -3.85"/>',
lock: '<path d="M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6"/><path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0"/><path d="M8 11v-4a4 4 0 1 1 8 0v4"/>',
cake: '<path d="M3 20h18v-8a3 3 0 0 0 -3 -3h-12a3 3 0 0 0 -3 3v8"/><path d="M3 14.803c.312 .135 .654 .204 1 .197a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1c.35 .007 .692 -.062 1 -.197"/><path d="M12 4l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737"/>',
beach: '<path d="M17.553 16.75a7.5 7.5 0 0 0 -10.606 0"/><path d="M18 3.804a6 6 0 0 0 -8.196 2.196l10.392 6a6 6 0 0 0 -2.196 -8.196"/><path d="M16.732 10c1.658 -2.87 2.225 -5.644 1.268 -6.196c-.957 -.552 -3.075 1.326 -4.732 4.196"/><path d="M15 9l-3 5.196"/><path d="M3 19.25a2.4 2.4 0 0 1 1 -.25a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 1 .25"/>',
reply: '<path d="M9 14l-4 -4l4 -4"/><path d="M5 10h11a4 4 0 1 1 0 8h-1"/>',
checklist: '<path d="M9.615 20h-2.615a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8"/><path d="M14 19l2 2l4 -4"/><path d="M9 8h4"/><path d="M9 12h2"/>',
upload: '<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"/><path d="M7 9l5 -5l5 5"/><path d="M12 4l0 12"/>',
refresh: '<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4"/><path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/>',
tag: '<path d="M6.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/><path d="M3 6v5.172a2 2 0 0 0 .586 1.414l7.71 7.71a2.41 2.41 0 0 0 3.408 0l5.592 -5.592a2.41 2.41 0 0 0 0 -3.408l-7.71 -7.71a2 2 0 0 0 -1.414 -.586h-5.172a3 3 0 0 0 -3 3"/>',
} as const;
const FILLED = {
star: '<path d="M8.243 7.34l-6.38 .925l-.113 .023a1 1 0 0 0 -.44 1.684l4.622 4.499l-1.09 6.355l-.013 .11a1 1 0 0 0 1.464 .944l5.706 -3l5.693 3l.1 .046a1 1 0 0 0 1.352 -1.1l-1.091 -6.355l4.624 -4.5l.078 -.085a1 1 0 0 0 -.633 -1.62l-6.38 -.926l-2.852 -5.78a1 1 0 0 0 -1.794 0l-2.853 5.78z"/>',
tag: '<path d="M11.172 2a3 3 0 0 1 2.121 .879l7.71 7.71a3.41 3.41 0 0 1 0 4.822l-5.592 5.592a3.41 3.41 0 0 1 -4.822 0l-7.71 -7.71a3 3 0 0 1 -.879 -2.121v-5.172a4 4 0 0 1 4 -4zm-3.672 3.5a2 2 0 0 0 -1.995 1.85l-.005 .15a2 2 0 1 0 2 -2"/>',
} 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 (
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" ` +
`stroke="${value}" stroke-opacity="${opacity}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round">` +
`${OUTLINE[name]}</svg>`
);
}
/** Filled Tabler icon (star, tag) as an SVG string. */
export function filledIcon(name: FilledIconName, color: string): string {
const { value, opacity } = paint(color);
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" ` +
`fill="${value}" fill-opacity="${opacity}">${FILLED[name]}</svg>`
);
}
/**
* 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
? `<circle cx="32" cy="32" r="${r}" fill="none" stroke="${p.value}" stroke-opacity="${p.opacity}" stroke-width="${strokeWidth}" ` +
`stroke-linecap="round" stroke-dasharray="${(f * c).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 32 32)"/>`
: '';
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">` +
`<circle cx="32" cy="32" r="${r}" fill="none" stroke="${t.value}" stroke-opacity="${t.opacity}" stroke-width="${strokeWidth}"/>` +
`${arc}</svg>`
);
}
+450
View File
@@ -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, unknown>, 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<JMAPClient | null> {
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<string, Person & { count: number }>();
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<string, { unread: number; total: number; latest?: MailItem }>;
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<MailSection> {
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<string, number>(
((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<Email & {
attachments?: Array<{ name?: string; type?: string; disposition?: string; cid?: string }>;
}>)
.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<MailSection['tagCounts']> {
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<string, Record<string, unknown>>): Promise<boolean> {
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<boolean> {
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<boolean> {
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<Email, 'id' | 'mailboxIds'> | undefined;
const destination = target === 'archive' ? findArchiveMailbox(mailboxes) : findTrashMailbox(mailboxes);
if (!email || !destination) return false;
const patch: Record<string, unknown> = { [`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<boolean> {
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<boolean> {
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;
}
File diff suppressed because it is too large Load Diff
+806
View File
@@ -0,0 +1,806 @@
// Board 4, "Hubs and account": the whole day in one tile, mail next to the
// next event, recent files and mail attachments, and account status (the
// vacation responder switch and the quota ring).
import React from 'react';
import { FlexWidget, SvgWidget, TextWidget } from 'react-native-android-widget';
import { action, links, open, type WidgetClick } from '../clicks';
import { dueKind, nextEvent, sortTasks, tasksDueNow, upcomingTimed } from '../derive';
import { addDays, sameDay, startOfDay, type Fmt } from '../format';
import { ring, type IconName } from '../icons';
import { AGENDA_ROW_HEIGHT, AgendaRow, MAIL_ROW_HEIGHT, MailRow } from '../parts';
import {
Divider,
EventChip,
GhostIcon,
Header,
Icon,
Overlay,
Pill,
Placeholder,
SectionLabel,
Spacer,
Surface,
Txt,
WIDGET_RADIUS,
} from '../primitives';
import type { AttachmentGroup, EventItem, FileItem, TaskItem, WidgetSnapshot } from '../snapshot';
import type { WidgetPalette } from '../theme';
import type { Layout } from './types';
// File-type colours of the webmail's file list and attachment chips. They are
// the same in both themes, like the Tailwind classes they copy.
const TYPE_COLOR = {
folder: '#3b82f6',
spreadsheet: '#16a34a',
presentation: '#ea580c',
image: '#8b5cf6',
pdf: '#ef4444',
} as const;
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif', 'heic', 'heif']);
const SPREADSHEET_EXTENSIONS = new Set(['xls', 'xlsx', 'ods', 'numbers', 'csv', 'tsv']);
const PRESENTATION_EXTENSIONS = new Set(['ppt', 'pptx', 'odp', 'key']);
function extension(name: string): string {
const dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
}
function inboxUnread(s: WidgetSnapshot): number {
return s.mail.folders.find((x) => x.role === 'inbox')?.unread ?? 0;
}
/** Rough rendered width of a single line, for deciding what fits in a row. */
function textWidth(text: string, size: number, factor = 0.56): number {
return Math.ceil(text.length * size * factor);
}
/** f.bytes without a trailing ".0" ("5 GB", not "5.0 GB"), as the webmail prints sizes. */
function size(f: Fmt, n: number): string {
return f.bytes(n).replace(/\.0 /, ' ');
}
function capitalize(text: string): string {
return text ? text.charAt(0).toUpperCase() + text.slice(1) : text;
}
/** "Monday, Sep 28, 2026" in the app's language. */
function longDate(f: Fmt, ms: number): string {
try {
return new Intl.DateTimeFormat(f.locale, { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' })
.format(new Date(ms));
} catch {
return `${f.weekdayLong(ms)}, ${f.fullDate(ms)}`;
}
}
/** Short date, with the year only when it is not this year. */
function shortDate(f: Fmt, ms: number, now: number): string {
return new Date(ms).getFullYear() === new Date(now).getFullYear() ? f.dayMonth(ms) : f.fullDate(ms);
}
/** Start time of an event, prefixed with the weekday when it is not today ("Tue 10:00"). */
function startLabel(f: Fmt, e: EventItem, now: number): string {
return sameDay(e.start, now) ? f.time(e.start) : `${f.weekdayShort(e.start)} ${f.time(e.start)}`;
}
/* ------------------------------------------------------------------------ */
/* Today hub */
/* ------------------------------------------------------------------------ */
const HUB_HEADER_HEIGHT = 44;
/** SectionLabel: 11sp line plus 8 + 4 padding. */
const SECTION_LABEL_HEIGHT = 28;
const HUB_TASK_HEIGHT = 32;
/**
* Tasks for the hub: open tasks that are overdue or due today (sorted like the
* task list), then tasks ticked off that were due today, so a tick made from
* the widget stays visible until the day moves on.
*/
function hubTasks(items: TaskItem[], now: number): { list: TaskItem[]; open: number } {
const open = sortTasks(tasksDueNow(items.filter((t) => !t.done), now), now);
const doneToday = items.filter((t) => t.done && t.due !== undefined && sameDay(t.due, now));
return { list: [...open, ...doneToday], open: open.length };
}
function HubTaskLine({ p, f, task, now }: { p: WidgetPalette; f: Fmt; task: TaskItem; now: number }) {
const kind = task.done ? 'today' : dueKind(task, now);
const dueColor = task.done
? p.muted
: kind === 'overdue'
? p.overdue
: kind === 'today'
? p.dueToday
: p.muted;
const dueLabel = task.due === undefined
? ''
: kind === 'overdue'
? f.t('widgets.tasks.overdue', 'Overdue')
: kind === 'today'
? f.t('widgets.tasks.today', 'Today')
: '';
return (
<FlexWidget
style={{
flexDirection: 'row',
alignItems: 'center',
width: 'match_parent',
height: HUB_TASK_HEIGHT,
paddingLeft: 8,
paddingRight: 14,
}}
>
{/* 32dp touch target around the 20dp box, which sits at the 14dp inset. */}
<FlexWidget
{...action('toggleTask', { id: task.id })}
accessibilityLabel={task.done ? f.t('widgets.tasks.mark_incomplete', 'Mark as not done') : f.t('widgets.tasks.mark_complete', 'Mark as done')}
style={{ width: 32, height: 32, justifyContent: 'center', alignItems: 'center' }}
>
<FlexWidget
style={{
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: task.done ? p.success : (`${p.muted}66` as `#${string}`),
backgroundColor: task.done ? p.success : p.bg,
justifyContent: 'center',
alignItems: 'center',
}}
>
{task.done ? <Icon name="check" color="#ffffff" size={12} /> : null}
</FlexWidget>
</FlexWidget>
<Spacer size={6} horizontal />
<FlexWidget style={{ flex: 1 }}>
<Txt text={task.title || f.t('widgets.tasks.no_title', '(no title)')} color={task.done ? p.muted : p.fg} size={14} weight="500" />
</FlexWidget>
{dueLabel ? <Spacer size={8} horizontal /> : null}
{dueLabel ? <Txt text={dueLabel} color={dueColor} size={12} /> : null}
</FlexWidget>
);
}
export const TodayHubLayout: Layout = ({ s, p, f, now, height }) => {
const next = s.calendar.supported ? nextEvent(s.calendar.events, now) : null;
const unreadMail = s.mail.inbox.filter((m) => m.unread);
const unreadCount = Math.max(inboxUnread(s), unreadMail.length);
const tasks = s.tasks.supported ? hubTasks(s.tasks.items, now) : { list: [], open: 0 };
// Each section first gets its label and one row, in order; what is left
// then goes to a second message and more tasks in turn.
let budget = height - 2 - HUB_HEADER_HEIGHT;
const nextCost = SECTION_LABEL_HEIGHT + AGENDA_ROW_HEIGHT;
const showNext = !!next && budget >= nextCost;
if (showNext) budget -= nextCost;
let mailRows = 0;
const mailCost = SECTION_LABEL_HEIGHT + 1 + MAIL_ROW_HEIGHT.compact;
if (unreadMail.length > 0 && budget >= mailCost) {
mailRows = 1;
budget -= mailCost;
}
let taskRows = 0;
const taskCost = SECTION_LABEL_HEIGHT + HUB_TASK_HEIGHT;
if (tasks.open > 0 && budget >= taskCost) {
taskRows = 1;
budget -= taskCost;
}
const mailMax = Math.min(2, unreadMail.length);
const taskMax = Math.min(3, tasks.list.length);
let grew = true;
while (grew) {
grew = false;
if (mailRows > 0 && mailRows < mailMax && budget >= MAIL_ROW_HEIGHT.compact) {
mailRows++;
budget -= MAIL_ROW_HEIGHT.compact;
grew = true;
}
if (taskRows > 0 && taskRows < taskMax && budget >= HUB_TASK_HEIGHT) {
taskRows++;
budget -= HUB_TASK_HEIGHT;
grew = true;
}
}
let nextLabel = '';
if (next) {
const start = next.event.start;
const tomorrow = sameDay(start, addDays(startOfDay(now), 1));
nextLabel = next.running
? f.t('widgets.hub.now', 'Now')
: sameDay(start, now)
? f.t('widgets.hub.next', 'Next')
: f.t('widgets.hub.next_on', 'Next · {day}', {
day: tomorrow ? f.t('widgets.calendar.tomorrow', 'Tomorrow') : f.weekdayDayMonth(start),
});
}
const mail = unreadMail.slice(0, mailRows);
const taskList = tasks.list.slice(0, taskRows);
const empty = !showNext && mailRows === 0 && taskRows === 0;
return (
<Surface p={p}>
<FlexWidget {...open(links.calendar())} style={{ width: 'match_parent' }}>
<FlexWidget
style={{
flexDirection: 'row',
alignItems: 'center',
width: 'match_parent',
height: HUB_HEADER_HEIGHT - 1,
paddingHorizontal: 14,
}}
>
<Txt text={f.t('widgets.calendar.today', 'Today')} color={p.today} size={14} weight="600" />
<Spacer size={8} horizontal />
<FlexWidget style={{ flex: 1 }}>
<Txt text={longDate(f, now)} color={p.muted} size={12} />
</FlexWidget>
</FlexWidget>
<Divider p={p} />
</FlexWidget>
{empty ? (
<Placeholder p={p} iconName="check" title={f.t('widgets.hub.all_clear', "You're all caught up")} click={open(links.calendar())} />
) : null}
{showNext && next ? (
<FlexWidget style={{ width: 'match_parent' }}>
<FlexWidget {...open(links.calendar())} style={{ width: 'match_parent' }}>
<SectionLabel p={p} text={nextLabel} />
</FlexWidget>
<AgendaRow p={p} f={f} e={next.event} last={mailRows === 0 && taskRows === 0} />
</FlexWidget>
) : null}
{mailRows > 0 ? (
<FlexWidget style={{ width: 'match_parent' }}>
<FlexWidget {...open(links.inbox())} style={{ width: 'match_parent' }}>
<SectionLabel p={p} text={f.t('widgets.hub.mail_unread', 'Mail · {count} unread', { count: unreadCount })} />
</FlexWidget>
<Divider p={p} />
{mail.map((m, i) => (
<MailRow key={m.id} p={p} f={f} m={m} now={now} compact last={taskRows === 0 && i === mail.length - 1} />
))}
</FlexWidget>
) : null}
{taskRows > 0 ? (
<FlexWidget style={{ width: 'match_parent' }}>
<SectionLabel p={p} text={f.t('widgets.hub.tasks_due', 'Tasks · {count} due', { count: tasks.open })} />
{taskList.map((task) => (
<HubTaskLine key={task.id} p={p} f={f} task={task} now={now} />
))}
</FlexWidget>
) : null}
</Surface>
);
};
/* ------------------------------------------------------------------------ */
/* Mail and next event */
/* ------------------------------------------------------------------------ */
export const MailAndNextLayout: Layout = ({ s, p, f, now, height }) => {
const tight = height < 140;
const pad = tight ? 10 : 14;
const unreadMail = s.mail.inbox.filter((m) => m.unread);
const unread = Math.max(inboxUnread(s), unreadMail.length);
const latest = unreadMail[0];
const digits = String(unread).length;
const big = (tight ? 28 : 36) - (digits >= 4 ? 8 : 0);
const upcoming = s.calendar.supported ? upcomingTimed(s.calendar.events, now) : [];
const event = upcoming[0];
const following = upcoming[1];
const running = !!event && event.start <= now;
const when = event ? (running ? f.t('widgets.hub.now', 'Now') : capitalize(f.relative(event.start, now))) : '';
const range = event
? `${sameDay(event.start, now) ? '' : `${f.weekdayShort(event.start)} `}${f.time(event.start)} – ${f.time(event.end)}`
: '';
return (
<Surface p={p} style={{ flexDirection: 'row' }}>
<FlexWidget
{...open(links.inbox())}
style={{
width: 0,
flex: 1,
height: 'match_parent',
padding: pad,
backgroundColor: unread > 0 ? p.unreadRow : p.bg,
borderTopLeftRadius: WIDGET_RADIUS - 1,
borderBottomLeftRadius: WIDGET_RADIUS - 1,
}}
>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
<Icon name="inbox" color={p.folder.inbox} size={16} />
<Spacer size={6} horizontal />
<Txt text={f.t('widgets.mail.inbox', 'Inbox')} color={p.fg} size={14} weight="600" />
</FlexWidget>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'flex-end', marginTop: 4 }}>
<TextWidget text={String(unread)} maxLines={1} style={{ color: p.fg, fontSize: big, fontWeight: '700' }} />
<Spacer size={4} horizontal />
{/* Lines the small label up with the big number's baseline. */}
<Txt
text={f.t('widgets.mail.unread', 'unread')}
color={p.muted}
size={13}
style={{ paddingBottom: Math.round((big - 13) * 0.27) }}
/>
</FlexWidget>
<Spacer />
{latest ? (
<FlexWidget {...open(links.message(latest))} style={{ width: 'match_parent' }}>
<Txt text={latest.fromName || latest.fromEmail} color={p.fg} size={14} weight="700" />
<Txt text={latest.subject || f.t('widgets.mail.no_subject', '(no subject)')} color={p.fg} size={13} weight="600" />
</FlexWidget>
) : (
<Txt text={f.t('widgets.mail.all_caught_up', 'All caught up')} color={p.muted} size={13} />
)}
</FlexWidget>
<FlexWidget style={{ width: 1, height: 'match_parent', backgroundColor: p.border }} />
<FlexWidget
{...open(event ? links.event(event) : links.calendar())}
style={{ width: 0, flex: 1, height: 'match_parent', padding: pad }}
>
{event ? (
<FlexWidget style={{ width: 'match_parent', flex: 1 }}>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
<Icon name="clock" color={p.today} size={14} />
<Spacer size={5} horizontal />
<Txt text={when} color={p.today} size={13} weight="600" />
</FlexWidget>
<Spacer size={tight ? 4 : 8} />
<Txt
text={event.title || f.t('widgets.calendar.untitled', '(no title)')}
color={p.fg}
size={15}
weight="600"
lines={following || tight ? 1 : 2}
/>
<Txt text={range} color={p.muted} size={13} />
<Spacer />
{following ? (
<EventChip
title={following.title || f.t('widgets.calendar.untitled', '(no title)')}
time={startLabel(f, following, now)}
color={following.color}
click={open(links.event(following))}
/>
) : null}
</FlexWidget>
) : (
<FlexWidget style={{ width: 'match_parent', flex: 1 }}>
<Icon name="clock" color={p.muted} size={14} />
<Spacer size={8} />
<Txt text={f.t('widgets.hub.no_events', 'No upcoming events')} color={p.muted} size={13} lines={2} />
</FlexWidget>
)}
</FlexWidget>
</Surface>
);
};
/* ------------------------------------------------------------------------ */
/* Recent files */
/* ------------------------------------------------------------------------ */
const FILE_ROW_HEIGHT = 42;
const FILE_ROW_MIN_HEIGHT = 34;
function fileIcon(item: FileItem, p: WidgetPalette): { name: IconName; color: string } {
if (item.isFolder) return { name: 'folder', color: TYPE_COLOR.folder };
const ext = extension(item.name);
const type = item.type.toLowerCase();
if (
SPREADSHEET_EXTENSIONS.has(ext) ||
type.includes('spreadsheet') ||
type.includes('ms-excel') ||
type === 'text/csv' ||
type === 'text/tab-separated-values'
) {
return { name: 'fileSpreadsheet', color: TYPE_COLOR.spreadsheet };
}
if (PRESENTATION_EXTENSIONS.has(ext) || type.includes('presentation') || type.includes('ms-powerpoint')) {
return { name: 'presentation', color: TYPE_COLOR.presentation };
}
if (type.startsWith('image/') || IMAGE_EXTENSIONS.has(ext)) return { name: 'photo', color: TYPE_COLOR.image };
return { name: 'fileText', color: p.muted };
}
export const RecentFilesLayout: Layout = ({ s, p, f, now, height }) => {
if (!s.files.supported) {
return (
<Surface p={p}>
<Placeholder p={p} iconName="server" title={f.t('widgets.files.unsupported', "Files aren't available on this server")} />
</Surface>
);
}
const header = (
<Header
p={p}
iconName="server"
title={f.t('widgets.shortcuts.files', 'Files')}
count={f.t('widgets.files.recent', 'Recently changed')}
click={open(links.files())}
trailing={<GhostIcon p={p} iconName="upload" click={open(links.files())} />}
/>
);
const items = s.files.items;
if (items.length === 0) {
return (
<Surface p={p}>
{header}
<Placeholder p={p} iconName="folder" title={f.t('widgets.files.empty', 'No files yet')} click={open(links.files())} />
</Surface>
);
}
// Rows share the space below the header, between 34 and 42dp each.
const avail = height - 2 - 41;
const count = Math.max(1, Math.min(items.length, Math.floor(avail / FILE_ROW_MIN_HEIGHT)));
const rowHeight = Math.max(FILE_ROW_MIN_HEIGHT, Math.min(FILE_ROW_HEIGHT, Math.floor(avail / count)));
const shown = items.slice(0, count);
return (
<Surface p={p}>
{header}
{shown.map((item, i) => {
const kind = fileIcon(item, p);
const date = item.modified > 0 ? shortDate(f, item.modified, now) : '';
const meta = item.isFolder ? date : [size(f, item.size), date].filter(Boolean).join(' · ');
return (
<FlexWidget
key={item.id}
{...open(links.files())}
style={{
flexDirection: 'row',
alignItems: 'center',
width: 'match_parent',
height: rowHeight,
paddingHorizontal: 14,
borderBottomWidth: i === shown.length - 1 ? 0 : 1,
borderBottomColor: p.border,
}}
>
<Icon name={kind.name} color={kind.color} size={18} />
<Spacer size={12} horizontal />
<FlexWidget style={{ flex: 1 }}>
<Txt text={item.name} color={p.fg} size={14} />
</FlexWidget>
{meta ? <Spacer size={8} horizontal /> : null}
{meta ? <Txt text={meta} color={p.muted} size={12} /> : null}
</FlexWidget>
);
})}
</Surface>
);
};
/* ------------------------------------------------------------------------ */
/* Recent attachments */
/* ------------------------------------------------------------------------ */
type AttachmentFile = AttachmentGroup['files'][number];
interface AttachmentDensity {
header: number;
padTop: number;
padBottom: number;
gap: number;
chip: number;
}
// Most comfortable first; the first one that shows the most groups wins.
const ATTACHMENT_DENSITIES: AttachmentDensity[] = [
{ header: 36, padTop: 6, padBottom: 6, gap: 4, chip: 28 },
{ header: 32, padTop: 3, padBottom: 4, gap: 3, chip: 28 },
{ header: 32, padTop: 3, padBottom: 3, gap: 2, chip: 24 },
];
const SENDER_LINE_HEIGHT = 18;
const CHIP_GAP = 6;
function groupHeight(d: AttachmentDensity): number {
return d.padTop + SENDER_LINE_HEIGHT + d.gap + d.chip + d.padBottom + 1;
}
function attachmentIcon(file: AttachmentFile, p: WidgetPalette): { name: IconName; color: string } {
const ext = extension(file.name);
const type = file.type.toLowerCase();
if (type === 'application/pdf' || ext === 'pdf') return { name: 'fileText', color: TYPE_COLOR.pdf };
if (type.startsWith('image/') || IMAGE_EXTENSIONS.has(ext)) return { name: 'photo', color: TYPE_COLOR.image };
return { name: 'file', color: p.muted };
}
/** Border, padding, icon and gap around the file name. */
const CHIP_CHROME = 2 + 16 + 14 + 6;
function moreChipWidth(rest: number): number {
return rest > 0 ? CHIP_GAP + 2 + 16 + textWidth(`+${rest}`, 12, 0.6) : 0;
}
/**
* The chips that fit on one line of `inner` dp, each at most about half the
* widget, and how many files are left over for the "+N" chip.
*/
function fitChips(files: AttachmentFile[], inner: number) {
const maxChip = Math.max(64, Math.floor(inner / 2) - CHIP_GAP / 2);
const chips = files.map((file) => {
const natural = CHIP_CHROME + textWidth(file.name, 12, 0.55);
return { file, width: Math.min(natural, maxChip), fixed: natural > maxChip };
});
const total = (k: number) =>
chips.slice(0, k).reduce((sum, c) => sum + c.width, 0) + CHIP_GAP * (k - 1) + moreChipWidth(chips.length - k);
let k = chips.length;
while (k > 1 && total(k) > inner) k--;
const shown = chips.slice(0, k);
if (total(k) > inner) {
shown[0] = { ...shown[0], width: Math.max(48, inner - moreChipWidth(chips.length - 1)), fixed: true };
}
return { shown, more: chips.length - k };
}
function AttachmentChip({
p,
file,
width,
fixed,
height,
}: {
p: WidgetPalette;
file: AttachmentFile;
width: number;
fixed: boolean;
height: number;
}) {
const kind = attachmentIcon(file, p);
return (
<FlexWidget
style={{
flexDirection: 'row',
alignItems: 'center',
height,
width: fixed ? width : 'wrap_content',
paddingHorizontal: 8,
borderWidth: 1,
borderColor: p.border,
borderRadius: 6,
}}
>
<Icon name={kind.name} color={kind.color} size={14} />
<Spacer size={6} horizontal />
{fixed ? (
<FlexWidget style={{ flex: 1 }}>
<Txt text={file.name} color={p.fg} size={12} />
</FlexWidget>
) : (
<Txt text={file.name} color={p.fg} size={12} />
)}
</FlexWidget>
);
}
export const AttachmentsLayout: Layout = ({ s, p, f, now, width, height }) => {
const groups = s.mail.attachments;
let density = ATTACHMENT_DENSITIES[0];
let fit = 0;
for (const d of ATTACHMENT_DENSITIES) {
// The last group has no bottom rule, hence the + 1.
const n = Math.min(groups.length, Math.floor((height - 2 - (d.header + 1) + 1) / groupHeight(d)));
if (n > fit) {
fit = n;
density = d;
}
}
const header = (
<Header
p={p}
height={density.header}
iconName="paperclip"
title={f.t('widgets.attachments.title', 'Attachments')}
count={f.t('widgets.attachments.from_mail', 'from mail')}
click={open(links.inbox())}
/>
);
if (groups.length === 0) {
return (
<Surface p={p}>
{header}
<Placeholder p={p} iconName="paperclip" title={f.t('widgets.attachments.empty', 'No recent attachments')} click={open(links.inbox())} />
</Surface>
);
}
const shown = groups.slice(0, Math.max(1, fit));
const inner = width - 2 - 28;
return (
<Surface p={p}>
{header}
{shown.map((g, i) => {
const { shown: chips, more } = fitChips(g.files, inner);
return (
<FlexWidget
key={g.emailId}
{...open(links.message({ id: g.emailId, accountId: g.accountId }))}
style={{
width: 'match_parent',
paddingHorizontal: 14,
paddingTop: density.padTop,
paddingBottom: density.padBottom,
borderBottomWidth: i === shown.length - 1 ? 0 : 1,
borderBottomColor: p.border,
}}
>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent', height: SENDER_LINE_HEIGHT }}>
<FlexWidget style={{ flex: 1 }}>
<Txt text={g.fromName || f.t('widgets.attachments.unknown_sender', 'Unknown sender')} color={p.fg} size={13} weight="600" />
</FlexWidget>
<Spacer size={6} horizontal />
<Txt text={f.listDate(g.receivedAt, now)} color={p.muted} size={12} />
</FlexWidget>
<Spacer size={density.gap} />
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent' }}>
{chips.map((c, j) => (
<FlexWidget key={`${j}:${c.file.name}`} style={{ flexDirection: 'row' }}>
{j > 0 ? <Spacer size={CHIP_GAP} horizontal /> : null}
<AttachmentChip p={p} file={c.file} width={c.width} fixed={c.fixed} height={density.chip} />
</FlexWidget>
))}
{more > 0 ? <Spacer size={CHIP_GAP} horizontal /> : null}
{more > 0 ? (
<FlexWidget
style={{
flexDirection: 'row',
alignItems: 'center',
height: density.chip,
paddingHorizontal: 8,
borderWidth: 1,
borderColor: p.border,
borderRadius: 6,
}}
>
<Txt text={`+${more}`} color={p.muted} size={12} />
</FlexWidget>
) : null}
</FlexWidget>
</FlexWidget>
);
})}
</Surface>
);
};
/* ------------------------------------------------------------------------ */
/* Vacation responder */
/* ------------------------------------------------------------------------ */
/** The settings switch (`h-[22px] w-10 rounded-full`), drawn only; taps open settings. */
function Switch({ p, on, label, click }: { p: WidgetPalette; on: boolean; label: string; click: WidgetClick }) {
return (
<FlexWidget
{...click}
accessibilityLabel={label}
style={{
flexDirection: 'row',
justifyContent: on ? 'flex-end' : 'flex-start',
alignItems: 'center',
width: 40,
height: 22,
padding: 2,
borderRadius: 11,
backgroundColor: on ? p.primary : p.mutedBg,
}}
>
<FlexWidget style={{ width: 18, height: 18, borderRadius: 9, backgroundColor: on ? p.primaryFg : p.bg }} />
</FlexWidget>
);
}
export const VacationLayout: Layout = ({ s, p, f, width, height }) => {
const click = open(links.settings('vacation'));
const v = s.vacation;
if (!v) {
return (
<Surface p={p}>
<Placeholder p={p} iconName="beach" title={f.t('widgets.vacation.unsupported', "Vacation replies aren't available")} click={click} />
</Surface>
);
}
const title = f.t('widgets.vacation.title', 'Vacation Responder');
const until = v.to ? f.t('widgets.vacation.until', 'Until {date}', { date: f.weekdayDayMonth(v.to) }) : '';
const subject = v.subject ? f.t('widgets.vacation.subject', 'Subject: {subject}', { subject: v.subject }) : '';
// Drop spacing first, then the subject, then the end date, until it fits.
const pad = 14;
const avail = height - 2 - 2 * pad;
const titleLines = textWidth(title, 14, 0.58) > width - 2 - 2 * pad ? 2 : 1;
// Switch row, title (~17dp a line plus font padding), status pill.
const base = 22 + titleLines * 17 + 2 + 19;
let gapTop = 12;
let gapPill = 6;
let showUntil = !!until;
let showSubject = !!subject;
const need = () => base + gapTop + gapPill + (showUntil ? 16 : 0) + (showSubject ? 16 : 0);
if (need() > avail) {
gapTop = 6;
gapPill = 4;
}
if (need() > avail) showSubject = false;
if (need() > avail) showUntil = false;
return (
<Surface p={p} style={{ padding: pad }} click={click}>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent' }}>
<Icon name="beach" color={p.fg} size={20} />
<Spacer />
<Switch p={p} on={v.enabled} label={title} click={click} />
</FlexWidget>
<Spacer size={gapTop} />
<Txt text={title} color={p.fg} size={14} weight="600" lines={titleLines} />
<Spacer size={gapPill} />
{v.enabled ? (
<Pill text={f.t('widgets.vacation.active', 'Active')} bg={p.successBg} color={p.successText} />
) : (
<Pill text={f.t('widgets.vacation.inactive', 'Inactive')} bg={p.mutedBg} color={p.muted} />
)}
<Spacer />
{showUntil ? <Txt text={until} color={p.muted} size={12} /> : null}
{showSubject ? <Txt text={subject} color={p.muted} size={12} /> : null}
</Surface>
);
};
/* ------------------------------------------------------------------------ */
/* Storage */
/* ------------------------------------------------------------------------ */
function percent(f: Fmt, fraction: number): string {
try {
return new Intl.NumberFormat(f.locale, { style: 'percent', maximumFractionDigits: 0 }).format(fraction);
} catch {
return `${Math.round(fraction * 100)}%`;
}
}
export const StorageLayout: Layout = ({ s, p, f, width, height }) => {
const click = open(links.settings('account'));
const q = s.quota;
if (!q) {
return (
<Surface p={p}>
<Placeholder p={p} iconName="server" title={f.t('widgets.storage.unavailable', 'No quota information')} click={click} />
</Surface>
);
}
const limited = q.limit > 0;
const fraction = limited ? q.used / q.limit : 0;
const fill = fraction > 0.9 ? p.destructive : p.success;
const pad = 14;
const gap = height < 165 ? 6 : 8;
const titleHeight = 19;
const captionHeight = 16;
const ringSize = Math.max(
40,
Math.min(76, height - 2 - 2 * pad - titleHeight - captionHeight - 2 * gap, width - 2 - 2 * pad),
);
const centre = limited ? percent(f, fraction) : size(f, q.used);
const centreSize = Math.max(11, Math.round(((limited ? 16 : 13) * ringSize) / 76));
const caption = limited
? f.t('widgets.storage.used_of', '{used} of {total}', { used: size(f, q.used), total: size(f, q.limit) })
: f.t('widgets.storage.no_limit', 'No storage limit');
return (
<Surface p={p} style={{ padding: pad, alignItems: 'center' }} click={click}>
<FlexWidget style={{ width: 'match_parent' }}>
<Txt text={f.t('widgets.storage.title', 'Storage')} color={p.fg} size={14} weight="600" />
</FlexWidget>
<Spacer />
<Overlay style={{ width: ringSize, height: ringSize }}>
{/* ring() draws on a 64 viewBox; stroke 5 is ~6dp at 76dp like the webmail ring. */}
<SvgWidget svg={ring(fraction, p.mutedBg, fill, 5)} style={{ width: ringSize, height: ringSize }} />
<FlexWidget style={{ width: ringSize, height: ringSize, justifyContent: 'center', alignItems: 'center' }}>
<Txt text={centre} color={p.fg} size={centreSize} weight="700" />
</FlexWidget>
</Overlay>
<Spacer size={gap} />
<Txt text={caption} color={p.muted} size={12} />
<Spacer />
</Surface>
);
};
+291
View File
@@ -0,0 +1,291 @@
// Board 1, "Mail at a glance": inbox list, triage card, unread count, latest
// message, folder counts and shortcuts.
import React from 'react';
import { FlexWidget, TextWidget } from 'react-native-android-widget';
import { action, links, open } from '../clicks';
import { MAIL_ROW_HEIGHT, MailRow } from '../parts';
import {
Avatar,
Button,
Fab,
GhostIcon,
Header,
Icon,
Placeholder,
Spacer,
Surface,
Txt,
} from '../primitives';
import type { FolderCount } from '../snapshot';
import type { IconName } from '../icons';
import type { WidgetPalette } from '../theme';
import type { Layout } from './types';
function inboxCounts(s: Parameters<Layout>[0]['s']) {
const inbox = s.mail.folders.find((f) => f.role === 'inbox');
return { unread: inbox?.unread ?? 0, total: inbox?.total ?? 0 };
}
function CountText({ p, unread, total }: { p: WidgetPalette; unread: number; total: number }) {
return (
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
<Txt text={String(unread)} color={p.fg} size={12} weight="600" />
<Txt text={` / ${total}`} color={p.muted} size={12} />
</FlexWidget>
);
}
export const InboxLayout: Layout = ({ s, p, f, now, height }) => {
const compact = height < 190;
const headerHeight = compact ? 38 : 44;
const rowHeight = compact ? MAIL_ROW_HEIGHT.compact : MAIL_ROW_HEIGHT.full;
const rows = Math.max(1, Math.floor((height - headerHeight) / rowHeight));
const items = s.mail.inbox.slice(0, rows);
const { unread, total } = inboxCounts(s);
return (
<Surface p={p}>
<Header
p={p}
height={headerHeight}
iconName="inbox"
iconColor={p.folder.inbox}
title={f.t('widgets.mail.inbox', 'Inbox')}
click={open(links.inbox())}
trailing={(
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
<CountText p={p} unread={unread} total={total} />
{compact ? null : <GhostIcon p={p} iconName="search" click={open(links.search())} />}
<Spacer size={6} horizontal />
<Fab p={p} iconName="edit" size={compact ? 28 : 32} click={open(links.compose())} />
</FlexWidget>
)}
/>
{items.length === 0 ? (
<Placeholder p={p} iconName="inbox" title={f.t('widgets.mail.empty_inbox', 'Your inbox is empty')} click={open(links.inbox())} />
) : (
items.map((m, i) => (
<MailRow key={m.id} p={p} f={f} m={m} now={now} compact={compact} last={i === items.length - 1} />
))
)}
</Surface>
);
};
export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => {
const candidates = s.mail.inbox.filter((m) => m.unread);
const current = candidates.find((m) => m.id === local.triageId) ?? candidates[0];
const { unread } = inboxCounts(s);
if (!current) {
return (
<Surface p={p}>
<Placeholder
p={p}
iconName="check"
title={f.t('widgets.mail.all_caught_up', 'All caught up')}
body={f.t('widgets.mail.no_unread', 'No unread messages in your inbox.')}
click={open(links.inbox())}
/>
</Surface>
);
}
const position = candidates.indexOf(current) + 1;
const target = { id: current.id, accountId: current.accountId, jmapAccountId: current.jmapAccountId, widgetId };
return (
<Surface p={p} style={{ padding: 12 }}>
<FlexWidget {...open(links.message(current))} style={{ width: 'match_parent', flex: 1 }}>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent' }}>
<Avatar initials={current.initials} color={current.color} size={32} />
<Spacer size={10} horizontal />
<Txt text={current.fromName || current.fromEmail} color={p.fg} size={14} weight="700" />
<Spacer size={6} horizontal />
<Txt text={f.listDate(current.receivedAt, now)} color={p.fg} size={12} weight="600" />
<Spacer />
<Txt
text={f.t('widgets.mail.position', '{index} of {count}', { index: position, count: Math.max(unread, candidates.length) })}
color={p.muted}
size={12}
/>
</FlexWidget>
<Spacer size={6} />
<Txt text={current.subject || f.t('widgets.mail.no_subject', '(no subject)')} color={p.fg} size={14} weight="600" />
{current.preview ? <Txt text={current.preview} color={p.muted} size={13} lines={2} /> : null}
</FlexWidget>
<Spacer size={8} />
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
<Button p={p} label={f.t('widgets.mail.archive', 'Archive')} iconName="archive" flex={1} click={action('archive', target)} />
<Spacer size={6} horizontal />
<Button p={p} label={f.t('widgets.mail.delete', 'Delete')} iconName="trash" flex={1} click={action('trash', target)} />
<Spacer size={6} horizontal />
<Button p={p} label={f.t('widgets.mail.reply', 'Reply')} iconName="reply" variant="primary" flex={1} click={open(links.reply(current))} />
<Spacer size={6} horizontal />
<Button p={p} iconName="chevronRight" click={action('triageNext', { widgetId, id: current.id })} />
</FlexWidget>
</Surface>
);
};
export const UnreadCountLayout: Layout = ({ s, p, f }) => {
const { unread, total } = inboxCounts(s);
const senders = s.mail.inbox.filter((m) => m.unread);
const faces = senders.slice(0, 3);
const more = Math.max(0, unread - faces.length);
return (
<Surface p={p} style={{ padding: 14, justifyContent: 'space-between' }} click={open(links.inbox())}>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
<Icon name="inbox" color={p.folder.inbox} size={16} />
<Spacer size={6} horizontal />
<Txt text={f.t('widgets.mail.inbox', 'Inbox')} color={p.fg} size={14} weight="600" />
</FlexWidget>
<FlexWidget>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
<TextWidget text={String(unread)} style={{ color: p.fg, fontSize: 44, fontWeight: '700' }} />
<Spacer size={4} horizontal />
<TextWidget text={`/ ${total}`} style={{ color: p.muted, fontSize: 15, paddingBottom: 8 }} />
</FlexWidget>
<Txt text={f.t('widgets.mail.unread', 'unread')} color={p.muted} size={13} />
</FlexWidget>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
{faces.map((m, i) => (
<FlexWidget key={m.id} style={{ marginLeft: i === 0 ? 0 : -8 }}>
<Avatar initials={m.initials} color={m.color} size={24} ring={p.bg} />
</FlexWidget>
))}
{more > 0 ? <Spacer size={6} horizontal /> : null}
{more > 0 ? <Txt text={`+${more}`} color={p.muted} size={12} /> : null}
</FlexWidget>
</Surface>
);
};
export const LatestMessageLayout: Layout = ({ s, p, f, now }) => {
const latest = s.mail.inbox.find((m) => m.unread);
const { unread } = inboxCounts(s);
if (!latest) {
return (
<Surface p={p}>
<Placeholder p={p} iconName="check" title={f.t('widgets.mail.all_caught_up', 'All caught up')} click={open(links.inbox())} />
</Surface>
);
}
return (
<Surface p={p} style={{ padding: 14, backgroundColor: p.unreadRow }} click={open(links.message(latest))}>
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
<Avatar initials={latest.initials} color={latest.color} size={40} />
<Spacer />
<Txt text={f.listDate(latest.receivedAt, now)} color={p.fg} size={12} weight="600" />
</FlexWidget>
<Spacer size={6} />
<Txt text={latest.fromName || latest.fromEmail} color={p.fg} size={14} weight="700" />
<Txt text={latest.subject || f.t('widgets.mail.no_subject', '(no subject)')} color={p.fg} size={14} weight="600" lines={2} />
<Spacer />
{unread > 1 ? (
<Txt text={f.t('widgets.mail.more_unread', '+{count} more unread', { count: unread - 1 })} color={p.muted} size={12} />
) : null}
</Surface>
);
};
const FOLDER_ICON: Record<FolderCount['role'], IconName> = {
inbox: 'inbox',
drafts: 'file',
sent: 'send',
archive: 'archive',
junk: 'ban',
trash: 'trash',
scheduled: 'calendarClock',
};
function folderLink(role: FolderCount['role']): string {
if (role === 'inbox') return links.inbox();
if (role === 'scheduled') return links.scheduled();
return links.unified({ role });
}
export const FolderCountsLayout: Layout = ({ s, p, f, height }) => {
const scheduled = s.mail.scheduled.length;
const rows: Array<{ role: FolderCount['role']; name: string; count: number; strong: boolean }> = [];
for (const role of ['inbox', 'drafts', 'scheduled', 'junk', 'archive', 'sent'] as const) {
if (role === 'scheduled') {
if (scheduled > 0) rows.push({ role, name: f.t('widgets.mail.scheduled', 'Scheduled'), count: scheduled, strong: false });
continue;
}
const folder = s.mail.folders.find((x) => x.role === role);
if (!folder) continue;
const count = role === 'drafts' ? folder.total : folder.unread;
rows.push({ role, name: folder.name, count, strong: role !== 'drafts' && folder.unread > 0 });
}
const fit = Math.max(1, Math.floor((height - 40) / 31));
const colors: Record<FolderCount['role'], string> = {
inbox: p.folder.inbox,
drafts: p.folder.drafts,
sent: p.folder.sent,
archive: p.folder.archive,
junk: p.folder.junk,
trash: p.muted,
scheduled: p.folder.scheduled,
};
return (
<Surface p={p} style={{ paddingHorizontal: 12, paddingVertical: 8 }}>
<FlexWidget style={{ height: 26, justifyContent: 'center' }}>
<Txt text={f.t('widgets.mail.folders', 'Folders')} color={p.fg} size={14} weight="500" />
</FlexWidget>
{rows.slice(0, fit).map((row) => (
<FlexWidget
key={row.role}
{...open(folderLink(row.role))}
style={{ flexDirection: 'row', alignItems: 'center', height: 31, width: 'match_parent' }}
>
<Icon name={FOLDER_ICON[row.role]} color={colors[row.role]} size={16} />
<Spacer size={10} horizontal />
<FlexWidget style={{ flex: 1 }}>
<Txt text={row.name} color={p.fg} size={14} />
</FlexWidget>
{row.count > 0 ? (
<Txt text={String(row.count)} color={row.strong ? p.fg : p.muted} size={12} weight={row.strong ? '600' : '400'} />
) : null}
</FlexWidget>
))}
</Surface>
);
};
export const MailShortcutsLayout: Layout = ({ p, f }) => {
const tile = (key: string, icon: IconName, label: string, uri: string, primary = false) => (
<FlexWidget
key={key}
{...open(uri)}
style={{
// A zero base width makes the weights split the row evenly instead
// of only sharing out what the labels leave over.
width: 0,
flex: 1,
height: 'match_parent',
borderRadius: 8,
backgroundColor: primary ? p.primary : p.bg,
...(primary ? {} : { borderWidth: 1, borderColor: p.input }),
justifyContent: 'center',
alignItems: 'center',
}}
>
<Icon name={icon} color={primary ? p.primaryFg : p.fg} size={20} />
<Spacer size={4} />
<Txt text={label} color={primary ? p.primaryFg : p.fg} size={12} weight="500" />
</FlexWidget>
);
return (
<Surface p={p} style={{ padding: 10 }}>
<FlexWidget style={{ flexDirection: 'row', flex: 1, width: 'match_parent' }}>
{tile('compose', 'edit', f.t('widgets.shortcuts.compose', 'Compose'), links.compose(), true)}
<Spacer size={8} horizontal />
{tile('calendar', 'calendar', f.t('widgets.shortcuts.calendar', 'Calendar'), links.calendar())}
</FlexWidget>
<Spacer size={8} />
<FlexWidget style={{ flexDirection: 'row', flex: 1, width: 'match_parent' }}>
{tile('contacts', 'addressBook', f.t('widgets.shortcuts.contacts', 'Contacts'), links.contacts())}
<Spacer size={8} horizontal />
{tile('files', 'server', f.t('widgets.shortcuts.files', 'Files'), links.files())}
</FlexWidget>
</Surface>
);
};
+738
View File
@@ -0,0 +1,738 @@
// Board "More ways into mail": the unified inbox across accounts, starred
// mail, favourite people, search, scheduled sends and the offline outbox, the
// busiest tag and the last draft.
import React from 'react';
import { FlexWidget, TextWidget, type ColorProp } from 'react-native-android-widget';
import { links, open, type WidgetClick } from '../clicks';
import { daysUntil } from '../derive';
import type { Fmt } from '../format';
import { MAIL_ROW_HEIGHT, MailRow } from '../parts';
import {
Avatar,
Button,
Divider,
Dot,
FilledIcon,
Header,
Icon,
Overlay,
Pill,
Placeholder,
Spacer,
Surface,
Txt,
} from '../primitives';
import type { AccountSummary, Person, ScheduledItem } from '../snapshot';
import { normalizeHex, type WidgetPalette } from '../theme';
import type { Layout } from './types';
/**
* Rough rendered width of a line of text in dp. Widgets cannot measure text,
* so chips and header pills are budgeted with this (a glyph averages a bit
* over half the font size; erring wide keeps the last chip from clipping).
*/
function textWidth(text: string, size: number): number {
return Math.ceil(text.length * size * 0.58);
}
function clip(text: string, max: number): string {
return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text;
}
/** `border-sky-500/20`-style edge for a tinted pill: the tint's colour at 20%. */
function tintEdge(tint: ColorProp): ColorProp {
const m = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,/.exec(tint);
return m ? (`rgba(${m[1]}, ${m[2]}, ${m[3]}, 0.2)` as ColorProp) : tint;
}
/** Header rule like `Header`, for a leading glyph that is not an outline icon (the filled star). */
function LeadHeader({
p,
lead,
title,
count,
height,
click,
}: {
p: WidgetPalette;
lead: React.ReactNode;
title: string;
count?: string;
height: number;
click?: WidgetClick;
}) {
return (
<FlexWidget style={{ width: 'match_parent' }}>
<FlexWidget
{...click}
style={{
flexDirection: 'row',
alignItems: 'center',
width: 'match_parent',
height,
paddingLeft: 14,
paddingRight: 8,
}}
>
{lead}
<Spacer size={8} horizontal />
<Txt text={title} color={p.fg} size={14} weight="600" />
{count ? <Spacer size={8} horizontal /> : null}
{count ? <Txt text={count} color={p.muted} size={12} /> : null}
</FlexWidget>
<Divider p={p} />
</FlexWidget>
);
}
// ── All accounts ─────────────────────────────────────────────────────────
/** Opens the Mail tab on one account's inbox (`?account=` switches to it first). */
function accountInbox(accountId: string): string {
return `${links.inbox()}?account=${encodeURIComponent(accountId)}`;
}
function accountChipLabel(a: AccountSummary): string {
const label = clip(a.label, 12);
return a.unread > 0 ? `${label} ${a.unread}` : label;
}
/** `rounded-full bg-muted px-2 py-0.5 text-[11px]` with the account dot. */
function AccountChip({ p, a }: { p: WidgetPalette; a: AccountSummary }) {
return (
<FlexWidget
{...open(accountInbox(a.id))}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: p.mutedBg,
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 2,
}}
>
<Dot color={normalizeHex(a.color)} size={8} />
<Spacer size={5} horizontal />
<TextWidget text={accountChipLabel(a)} maxLines={1} style={{ color: p.muted, fontSize: 11, fontWeight: '500' }} />
</FlexWidget>
);
}
/** As many account chips as fit next to the title, then "+N" for the rest. */
function fitAccountChips(accounts: AccountSummary[], budget: number): { shown: AccountSummary[]; hidden: number } {
const chipWidth = (a: AccountSummary) => 8 + 8 + 5 + textWidth(accountChipLabel(a), 11) + 8;
const moreWidth = (n: number) => 16 + textWidth(`+${n}`, 11);
const shown: AccountSummary[] = [];
let used = 0;
for (let i = 0; i < accounts.length; i++) {
const width = chipWidth(accounts[i]) + (shown.length ? 6 : 0);
const rest = accounts.length - i - 1;
const reserve = rest > 0 ? 6 + moreWidth(rest) : 0;
if (used + width + reserve > budget) break;
used += width;
shown.push(accounts[i]);
}
const hidden = accounts.length - shown.length;
const room = budget - used - (shown.length ? 6 : 0);
return { shown, hidden: hidden > 0 && moreWidth(hidden) <= room ? hidden : 0 };
}
export const AllAccountsLayout: Layout = ({ s, p, f, now, width, height }) => {
const compact = height < 190;
const headerHeight = compact ? 38 : 44;
const rowHeight = compact ? MAIL_ROW_HEIGHT.compact : MAIL_ROW_HEIGHT.full;
const rows = Math.max(1, Math.floor((height - headerHeight) / rowHeight));
const items = s.mail.unified.slice(0, rows);
const colors = new Map(s.accounts.map((a) => [a.id, normalizeHex(a.color)]));
const title = f.t('widgets.mail.all_accounts', 'All accounts');
// Frame, header padding, icon and gap, title, header right padding, the
// chips' own right padding and a minimum gap before them.
const budget = width - 2 - 14 - 16 - 8 - textWidth(title, 14) - 8 - 4 - 8;
const { shown, hidden } = fitAccountChips(s.accounts, budget);
return (
<Surface p={p}>
<Header
p={p}
height={headerHeight}
iconName="inbox"
iconColor={p.folder.inbox}
title={title}
click={open(links.unified())}
trailing={(
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', paddingRight: 4 }}>
{shown.map((a, i) => (
<FlexWidget key={a.id} style={{ flexDirection: 'row', alignItems: 'center' }}>
{i > 0 ? <Spacer size={6} horizontal /> : null}
<AccountChip p={p} a={a} />
</FlexWidget>
))}
{hidden > 0 && shown.length > 0 ? <Spacer size={6} horizontal /> : null}
{hidden > 0 ? <Pill text={`+${hidden}`} bg={p.mutedBg} color={p.muted} /> : null}
</FlexWidget>
)}
/>
{items.length === 0 ? (
<Placeholder
p={p}
iconName="inbox"
title={f.t('widgets.mail.empty_all_accounts', 'Your inboxes are empty')}
click={open(links.unified())}
/>
) : (
items.map((m, i) => (
<MailRow
key={`${m.accountId}:${m.id}`}
p={p}
f={f}
m={m}
now={now}
compact={compact}
showAccountDot
accountColor={colors.get(m.accountId)}
last={i === items.length - 1}
/>
))
)}
</Surface>
);
};
// ── Starred ──────────────────────────────────────────────────────────────
export const StarredLayout: Layout = ({ s, p, f, now, height }) => {
const compact = height < 190;
const headerHeight = compact ? 38 : 44;
const rowHeight = compact ? MAIL_ROW_HEIGHT.compact : MAIL_ROW_HEIGHT.full;
const rows = Math.max(1, Math.floor((height - headerHeight) / rowHeight));
const items = s.mail.starred.slice(0, rows);
const count = Math.max(s.mail.starredCount, s.mail.starred.length);
const click = open(links.unified({ view: 'starred' }));
return (
<Surface p={p}>
<LeadHeader
p={p}
height={headerHeight}
lead={<FilledIcon name="star" color={p.star} size={16} />}
title={f.t('widgets.mail.starred', 'Starred')}
count={count > 0 ? String(count) : undefined}
click={click}
/>
{items.length === 0 ? (
<Placeholder
p={p}
iconName="star"
title={f.t('widgets.mail.no_starred', 'No starred mail')}
body={f.t('widgets.mail.no_starred_body', 'Star a message to keep it here.')}
click={click}
/>
) : (
// Every row is starred, so the per-row star would only repeat the header.
items.map((m, i) => (
<MailRow
key={m.id}
p={p}
f={f}
m={{ ...m, starred: false }}
now={now}
compact={compact}
last={i === items.length - 1}
/>
))
)}
</Surface>
);
};
// ── Favourite people ─────────────────────────────────────────────────────
/** "Sofia" for "Sofia Russo" (and for "Russo, Sofia"); the address's local part without a name. */
function givenName(person: Person): string {
const name = person.name.trim();
if (!name) return person.email.split('@')[0] || person.email;
const comma = name.indexOf(',');
const given = comma > 0 ? name.slice(comma + 1).trim() : name;
return given.split(/\s+/)[0] || name;
}
/** The unread badge on an avatar: `bg-primary` pill with a ring in the surface colour. */
function UnreadBadge({ p, count, left }: { p: WidgetPalette; count: number; left: number }) {
const text = count > 99 ? '99+' : String(count);
const width = Math.max(20, 10 + text.length * 7);
return (
<FlexWidget
style={{
marginLeft: left - width,
width,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: p.bg,
backgroundColor: p.primary,
justifyContent: 'center',
alignItems: 'center',
}}
>
<TextWidget text={text} allowFontScaling={false} style={{ color: p.primaryFg, fontSize: 11, fontWeight: '600' }} />
</FlexWidget>
);
}
function PersonTile({
p,
f,
person,
avatar,
showTime,
now,
}: {
p: WidgetPalette;
f: Fmt;
person: Person;
avatar: number;
showTime: boolean;
now: number;
}) {
// The badge sits 3dp above and 5dp right of the avatar, as in the list's
// avatar badges; the box leaves 5dp on both sides so the avatar stays centred.
const unread = person.unread > 0;
return (
<FlexWidget
{...open(links.search(person.email ? `from:${person.email}` : person.name))}
style={{ flex: 1, width: 0, alignItems: 'center' }}
>
<Overlay style={{ width: avatar + 10, height: avatar + 3 }}>
<FlexWidget style={{ marginLeft: 5, marginTop: 3 }}>
<Avatar initials={person.initials} color={person.color} size={avatar} />
</FlexWidget>
{unread ? <UnreadBadge p={p} count={person.unread} left={avatar + 10} /> : null}
</Overlay>
<Spacer size={4} />
<Txt text={givenName(person)} color={p.fg} size={12} weight="500" />
{showTime ? <Spacer size={4} /> : null}
{showTime ? (
<Txt
text={f.listDate(person.lastAt, now)}
color={unread ? p.fg : p.muted}
size={12}
weight={unread ? '600' : '400'}
/>
) : null}
</FlexWidget>
);
}
export const FavouritePeopleLayout: Layout = ({ s, p, f, now, width, height }) => {
const headerHeight = 38;
const people = s.mail.favourites.slice(0, 4);
const header = (
<Header
p={p}
height={headerHeight}
iconName="addressBook"
iconColor={p.muted}
title={f.t('widgets.people.favourites', 'Favourites')}
click={open(links.contacts())}
/>
);
if (people.length === 0) {
return (
<Surface p={p}>
{header}
<Placeholder
p={p}
iconName="users"
title={f.t('widgets.people.empty', 'No favourites yet')}
body={f.t('widgets.people.empty_body', 'The people you hear from most show up here.')}
click={open(links.contacts())}
/>
</Surface>
);
}
// Four equal columns (grid-cols-4 gap-2 px-3); the avatar shrinks to fit a
// narrow widget, and the time line goes first when the widget is short.
const body = height - 2 - headerHeight;
const topPad = body >= 110 ? 14 : 8;
const showTime = body - topPad >= 3 + 40 + 20 + 20;
const column = (width - 2 - 24 - 8 * 3) / 4;
const avatar = Math.max(
24,
Math.min(48, Math.floor(column - 10), body - topPad - 3 - 20 - (showTime ? 20 : 0)),
);
const cells: React.ReactNode[] = [];
for (let i = 0; i < 4; i++) {
if (i > 0) cells.push(<Spacer key={`gap-${i}`} size={8} horizontal />);
const person = people[i];
cells.push(
person ? (
<PersonTile
key={`person-${i}`}
p={p}
f={f}
person={person}
avatar={avatar}
showTime={showTime}
now={now}
/>
) : (
<FlexWidget key={`person-${i}`} style={{ flex: 1, width: 0 }} />
),
);
}
return (
<Surface p={p}>
{header}
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent', paddingHorizontal: 12, paddingTop: topPad }}>
{cells}
</FlexWidget>
</Surface>
);
};
// ── Search ───────────────────────────────────────────────────────────────
const CHIP_HEIGHT = 26;
const CHIP_GAP = 6;
/** `px-2.5` on both sides plus the 1dp border. */
const CHIP_CHROME = 22;
interface SearchChip {
label: string;
query: string;
}
/** Lays recent searches out in wrapped rows (flex-wrap), dropping what does not fit. */
function packChips(queries: string[], maxWidth: number, maxRows: number): SearchChip[][] {
const maxChars = Math.max(4, Math.floor((maxWidth - CHIP_CHROME) / (12 * 0.58)));
const rows: SearchChip[][] = [];
let row: SearchChip[] = [];
let used = 0;
for (const raw of queries) {
if (rows.length >= maxRows) break;
const query = raw.trim();
if (!query) continue;
const label = clip(query, maxChars);
const width = CHIP_CHROME + textWidth(label, 12);
if (row.length && used + CHIP_GAP + width > maxWidth) {
rows.push(row);
row = [];
used = 0;
if (rows.length >= maxRows) break;
}
used += (row.length ? CHIP_GAP : 0) + width;
row.push({ label, query });
}
if (row.length && rows.length < maxRows) rows.push(row);
return rows;
}
function SearchBar({ p, f, height }: { p: WidgetPalette; f: Fmt; height: number }) {
return (
<FlexWidget
{...open(links.search())}
style={{
flexDirection: 'row',
alignItems: 'center',
width: 'match_parent',
height,
paddingHorizontal: 16,
borderRadius: 999,
backgroundColor: p.mutedBg,
}}
>
<Icon name="search" color={p.muted} size={18} />
<Spacer size={10} horizontal />
<Txt text={f.t('widgets.search.placeholder', 'Search mail…')} color={p.muted} size={14} />
</FlexWidget>
);
}
export const MailSearchLayout: Layout = ({ s, p, f, width, height }) => {
// Below the bar: 10 gap, the label (~16), 10 gap, then rows of chips.
const room = height - 2 - 28 - 44 - 10 - 16 - 10;
const maxRows = Math.floor((room + CHIP_GAP) / (CHIP_HEIGHT + CHIP_GAP));
if (maxRows < 1) {
// 4x1: only the bar.
return (
<Surface p={p} style={{ paddingHorizontal: 12, justifyContent: 'center' }}>
<SearchBar p={p} f={f} height={Math.max(32, Math.min(44, height - 20))} />
</Surface>
);
}
const rows = packChips(s.mail.recentSearches, width - 2 - 28, maxRows);
return (
<Surface p={p} style={{ padding: 14 }}>
<SearchBar p={p} f={f} height={44} />
<Spacer size={10} />
<Txt
text={f.t('widgets.search.recent', 'Recent searches').toUpperCase()}
color={p.muted}
size={12}
weight="500"
style={{ letterSpacing: 0.05 }}
/>
<Spacer size={10} />
{rows.length === 0 ? (
<Txt text={f.t('widgets.search.no_recent', 'Your recent searches show up here.')} color={p.muted} size={12} lines={maxRows > 1 ? 2 : 1} />
) : (
rows.map((row, r) => (
<FlexWidget key={`row-${r}`} style={{ flexDirection: 'row', width: 'match_parent', marginTop: r === 0 ? 0 : CHIP_GAP }}>
{row.map((chip, i) => (
<FlexWidget key={`${r}-${i}`} style={{ flexDirection: 'row' }}>
{i > 0 ? <Spacer size={CHIP_GAP} horizontal /> : null}
<FlexWidget
{...open(links.search(chip.query))}
style={{
borderRadius: 999,
borderWidth: 1,
borderColor: p.border,
paddingHorizontal: 10,
paddingVertical: 4,
}}
>
<TextWidget text={chip.label} maxLines={1} style={{ color: p.fg, fontSize: 12 }} />
</FlexWidget>
</FlexWidget>
))}
</FlexWidget>
))
)}
</Surface>
);
};
// ── Scheduled and outbox ─────────────────────────────────────────────────
/** "Today, 08:00", "Tomorrow, 08:00", "Fri, 17:00", "Oct 3, 09:30". */
function sendAtLabel(f: Fmt, at: number, now: number): string {
if (at <= now) return f.t('widgets.scheduled.sending', 'Sending now');
const time = f.time(at);
const days = daysUntil(at, now);
if (days === 0) return f.t('widgets.scheduled.today_at', 'Today, {time}', { time });
if (days === 1) return f.t('widgets.scheduled.tomorrow_at', 'Tomorrow, {time}', { time });
const day = days < 7 ? f.weekdayShort(at) : f.dayMonth(at);
return f.t('widgets.scheduled.day_at', '{day}, {time}', { day, time });
}
/** The app's scheduled-send label: `sky-500/10` pill, `sky-500/20` edge, clock icon. */
function SendAtPill({ p, text }: { p: WidgetPalette; text: string }) {
return (
<FlexWidget
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 1,
borderRadius: 999,
borderWidth: 1,
borderColor: tintEdge(p.scheduledBg),
backgroundColor: p.scheduledBg,
}}
>
<Icon name="calendarClock" color={p.scheduledText} size={12} />
<Spacer size={4} horizontal />
<TextWidget text={text} maxLines={1} style={{ color: p.scheduledText, fontSize: 11, fontWeight: '500' }} />
</FlexWidget>
);
}
function ScheduledRow({
p,
f,
item,
now,
last,
}: {
p: WidgetPalette;
f: Fmt;
item: ScheduledItem;
now: number;
last?: boolean;
}) {
const to = item.to
? f.t('widgets.scheduled.to', 'To: {name}', { name: item.to })
: f.t('widgets.scheduled.no_recipient', '(no recipient)');
return (
<FlexWidget
{...open(links.scheduled())}
style={{
width: 'match_parent',
paddingLeft: 14,
paddingRight: 12,
paddingVertical: 4,
borderBottomWidth: last ? 0 : 1,
borderBottomColor: p.border,
}}
>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent', height: 18 }}>
<FlexWidget style={{ flex: 1 }}>
<Txt text={to} color={p.muted} size={14} weight="500" />
</FlexWidget>
<Spacer size={6} horizontal />
<SendAtPill p={p} text={sendAtLabel(f, item.sendAt, now)} />
</FlexWidget>
<Txt text={item.subject || f.t('widgets.mail.no_subject', '(no subject)')} color={p.subjectRead} size={13} />
</FlexWidget>
);
}
export const OutboxLayout: Layout = ({ s, p, f, now, width, height }) => {
const headerHeight = height < 190 ? 38 : 44;
const rows = Math.max(1, Math.floor((height - headerHeight) / MAIL_ROW_HEIGHT.compact));
const scheduled = [...s.mail.scheduled].sort((a, b) => a.sendAt - b.sendAt);
const items = scheduled.slice(0, rows);
const pending = s.mail.pendingChanges;
const click = open(links.scheduled());
const title = f.t('widgets.mail.scheduled', 'Scheduled');
const count = scheduled.length > 0 ? String(scheduled.length) : undefined;
let pill: string | null = null;
if (pending > 0) {
const room = width - 2 - 14 - 16 - 8 - textWidth(title, 14) - (count ? 6 + textWidth(count, 12) : 0) - 8 - 12;
const long = f.t('widgets.mail.in_outbox', '{count, plural, one {# change waiting} other {# changes waiting}}', { count: pending });
pill = 16 + textWidth(long, 11) <= room
? long
: f.t('widgets.mail.in_outbox_short', '{count} waiting', { count: pending });
}
return (
<Surface p={p}>
<Header
p={p}
height={headerHeight}
iconName="calendarClock"
iconColor={p.folder.scheduled}
title={title}
count={count}
click={click}
trailing={pill ? (
<FlexWidget style={{ paddingRight: 4 }}>
<Pill text={pill} bg={p.warningBg} color={p.warningText} />
</FlexWidget>
) : undefined}
/>
{items.length > 0 ? (
items.map((item, i) => (
<ScheduledRow key={item.id} p={p} f={f} item={item} now={now} last={i === items.length - 1} />
))
) : pending > 0 ? (
<Placeholder
p={p}
iconName="refresh"
title={f.t('widgets.scheduled.waiting', 'Waiting for network')}
body={f.t('widgets.scheduled.waiting_body', 'Changes you made offline go out when you are back online.')}
click={click}
/>
) : (
<Placeholder
p={p}
iconName="calendarClock"
title={f.t('widgets.scheduled.empty', 'Nothing scheduled')}
body={f.t('widgets.scheduled.empty_body', 'Messages you schedule to send later show up here.')}
click={click}
/>
)}
</Surface>
);
};
// ── Tag ──────────────────────────────────────────────────────────────────
export const TagLayout: Layout = ({ s, p, f, now }) => {
// Tags come sorted busiest first (most unread, then most messages).
const tag = s.mail.tags[0];
if (!tag) {
return (
<Surface p={p}>
<Placeholder
p={p}
iconName="tag"
title={f.t('widgets.tag.empty', 'No tags yet')}
body={f.t('widgets.tag.empty_body', 'Tags you add in the app show up here.')}
click={open(links.inbox())}
/>
</Surface>
);
}
const count = tag.unread > 0 ? tag.unread : tag.total;
const label = tag.unread > 0
? f.t('widgets.mail.unread', 'unread')
: f.t('widgets.tag.messages', '{count, plural, one {message} other {messages}}', { count });
const latest = tag.latestFrom
? [tag.latestFrom, tag.latestAt ? f.listDate(tag.latestAt, now) : ''].filter(Boolean).join(' · ')
: f.t('widgets.tag.total', '{count, plural, one {# message} other {# messages}}', { count: tag.total });
return (
<Surface p={p} style={{ padding: 14, justifyContent: 'space-between' }} click={open(links.inbox())}>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent' }}>
<FilledIcon name="tag" color={normalizeHex(tag.color)} size={16} />
<Spacer size={8} horizontal />
<FlexWidget style={{ flex: 1 }}>
<Txt text={tag.name} color={p.fg} size={14} weight="500" />
</FlexWidget>
</FlexWidget>
<FlexWidget>
<TextWidget text={String(count)} style={{ color: p.fg, fontSize: 44, fontWeight: '700' }} />
<Txt text={label} color={p.muted} size={13} />
</FlexWidget>
<Txt text={latest} color={p.muted} size={12} />
</Surface>
);
};
// ── Last draft ───────────────────────────────────────────────────────────
function editedLabel(f: Fmt, at: number, now: number): string {
const minutes = Math.floor((now - at) / 60000);
if (minutes < 1) return f.t('widgets.draft.edited_now', 'Edited just now');
if (minutes < 60) {
return f.t('widgets.draft.edited_minutes', '{count, plural, one {Edited # min ago} other {Edited # min ago}}', { count: minutes });
}
return f.t('widgets.draft.edited_at', 'Edited {when}', { when: f.listDate(at, now) });
}
export const LastDraftLayout: Layout = ({ s, p, f, now, height }) => {
const draft = s.mail.drafts[0];
if (!draft) {
return (
<Surface p={p}>
<Placeholder
p={p}
iconName="file"
title={f.t('widgets.draft.empty', 'No drafts')}
body={f.t('widgets.draft.empty_body', 'Messages you start and do not send wait here.')}
click={open(links.compose())}
/>
</Surface>
);
}
const count = Math.max(s.mail.draftCount, s.mail.drafts.length);
const resume = open(links.draft(draft));
return (
<Surface p={p} style={{ padding: 14 }} click={resume}>
<FlexWidget
{...open(links.unified({ role: 'drafts' }))}
style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent' }}
>
<Icon name="file" color={p.folder.drafts} size={16} />
<Spacer size={8} horizontal />
<Txt text={f.t('widgets.draft.title', 'Drafts')} color={p.fg} size={14} weight="500" />
{count > 0 ? <Spacer size={8} horizontal /> : null}
{count > 0 ? <Txt text={String(count)} color={p.muted} size={12} /> : null}
</FlexWidget>
<Spacer size={6} />
<Txt
text={draft.subject || f.t('widgets.mail.no_subject', '(no subject)')}
color={p.fg}
size={14}
weight="600"
lines={height >= 150 ? 2 : 1}
/>
<Spacer size={6} />
<Txt text={editedLabel(f, draft.receivedAt, now)} color={p.muted} size={12} />
<Spacer />
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
<Button p={p} label={f.t('widgets.draft.keep_writing', 'Keep writing')} flex={1} click={resume} />
</FlexWidget>
</Surface>
);
};
+665
View File
@@ -0,0 +1,665 @@
// Board 3, "Invites, tasks and free time": things you act on from the home
// screen - the invitation banner with its RSVP buttons, the working week as
// busy bars, the task list with working tick boxes, task progress, a
// countdown to the next all-day event and upcoming birthdays.
import React from 'react';
import { FlexWidget, OverlapWidget, SvgWidget } from 'react-native-android-widget';
import { action, links, open } from '../clicks';
import {
busyBlocks,
countdownTarget,
daysUntil,
eventsOnDay,
freeTime,
sortTasks,
startOfWeek,
tasksDueNow,
upcomingBirthdays,
type BusyBlock,
} from '../derive';
import { addDays, sameDay, startOfDay, type Fmt } from '../format';
import { ring, type IconName } from '../icons';
import { TASK_ROW_HEIGHT, TaskRow } from '../parts';
import {
Avatar,
Button,
Dot,
Fab,
Header,
Icon,
Placeholder,
Spacer,
Surface,
Txt,
} from '../primitives';
import type { EventItem, TaskItem } from '../snapshot';
import { normalizeHex, type WidgetPalette } from '../theme';
import type { Layout } from './types';
const MINUTE = 60000;
const HOUR = 3600000;
/** Working hours the free-time bars cover. */
const WORK_START = 8;
const WORK_END = 18;
/** The Birthdays calendar colour the app gives the calendar it builds from contacts. */
const BIRTHDAY_COLOR = '#eab308';
function untitled(f: Fmt, title: string): string {
return title || f.t('widgets.calendar.untitled', '(no title)');
}
/** "Thu, Oct 1 · 14:00 – 16:00", "Sun, Oct 4 · All day", or a range across days. */
function eventWhen(f: Fmt, e: EventItem): string {
const allDay = f.t('widgets.calendar.all_day', 'All day');
// An end at midnight (all-day events, or a timed one ending at 24:00) belongs to the day before.
const last = Math.max(e.start, e.end - 1);
if (e.allDay) {
return sameDay(e.start, last)
? `${f.weekdayDayMonth(e.start)} · ${allDay}`
: `${f.weekdayDayMonth(e.start)} – ${f.weekdayDayMonth(last)} · ${allDay}`;
}
if (sameDay(e.start, last)) return `${f.weekdayDayMonth(e.start)} · ${f.time(e.start)} – ${f.time(e.end)}`;
return `${f.weekdayDayMonth(e.start)} ${f.time(e.start)} – ${f.weekdayDayMonth(e.end)} ${f.time(e.end)}`;
}
/** Muted icon + text line (`flex items-center gap-1.5 text-[13px] text-muted-foreground`). */
function MetaLine({ p, iconName, text }: { p: WidgetPalette; iconName: IconName; text: string }) {
return (
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent', marginTop: 1 }}>
<Icon name={iconName} color={p.muted} size={14} />
<Spacer size={6} horizontal />
<FlexWidget style={{ flex: 1 }}>
<Txt text={text} color={p.muted} size={13} />
</FlexWidget>
</FlexWidget>
);
}
// ── Invitations ─────────────────────────────────────────────────────────────
export const InvitationsLayout: Layout = ({ s, p, f, height }) => {
const invitations = s.calendar.invitations;
const inv = invitations[0];
if (!inv) {
return (
<Surface p={p}>
<Placeholder
p={p}
iconName="calendar"
title={f.t('widgets.invites.empty', 'No invitations waiting')}
click={open(links.calendar())}
/>
</Surface>
);
}
// Natural height is ~139dp at 14dp padding; give up padding, then the people line, then button height.
const pad = height < 140 ? 10 : 14;
const showPeople = inv.participants.length > 0 && height >= 132;
const buttonHeight = height < 116 ? 28 : 32;
const label = (
inv.organizerName
? f.t('widgets.invites.label', 'Invitation · {name}', { name: inv.organizerName })
: f.t('widgets.invites.label_plain', 'Invitation')
).toUpperCase();
const names = inv.participants.slice(0, 3).map((x) => x.name);
const extra = inv.participants.length - names.length;
const people = `${names.join(', ')}${extra > 0 ? ` +${extra}` : ''}`;
const reply = (status: 'accepted' | 'tentative' | 'declined') => action('rsvp', { id: inv.id, status });
return (
<Surface p={p} style={{ padding: pad }}>
<FlexWidget {...open(links.event(inv))} style={{ flexDirection: 'row', width: 'match_parent' }}>
<FlexWidget
style={{
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: p.infoBg,
justifyContent: 'center',
alignItems: 'center',
}}
>
<Icon name="calendar" color={p.info} size={20} />
</FlexWidget>
<Spacer size={12} horizontal />
<FlexWidget style={{ flex: 1 }}>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent' }}>
<FlexWidget style={{ flex: 1 }}>
<Txt text={label} color={p.muted} size={10} weight="600" style={{ letterSpacing: 0.05 }} />
</FlexWidget>
{invitations.length > 1 ? <Spacer size={6} horizontal /> : null}
{invitations.length > 1 ? (
<Txt
text={f.t('widgets.invites.position', '{index} of {count}', { index: 1, count: invitations.length })}
color={p.muted}
size={11}
/>
) : null}
</FlexWidget>
<Txt text={untitled(f, inv.title)} color={p.fg} size={15} weight="600" />
<MetaLine p={p} iconName="clock" text={eventWhen(f, inv)} />
{showPeople ? <MetaLine p={p} iconName="users" text={people} /> : null}
</FlexWidget>
</FlexWidget>
<Spacer size={8} />
<Spacer />
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
<Button
p={p}
variant="primary"
label={f.t('widgets.invites.accept', 'Accept')}
flex={1}
height={buttonHeight}
click={reply('accepted')}
/>
<Spacer size={6} horizontal />
<Button p={p} label={f.t('widgets.invites.maybe', 'Maybe')} flex={1} height={buttonHeight} click={reply('tentative')} />
<Spacer size={6} horizontal />
<Button p={p} label={f.t('widgets.invites.decline', 'Decline')} flex={1} height={buttonHeight} click={reply('declined')} />
</FlexWidget>
</Surface>
);
};
// ── Free time ───────────────────────────────────────────────────────────────
/** Monday of the week the bars show; on a weekend that is next week's Monday. */
function barsMonday(now: number, weekStart: 0 | 1): number {
const monday = addDays(startOfWeek(now, weekStart), weekStart === 0 ? 1 : 0);
// Saturday 00:00 or later: this week's Mon-Fri is over, show the coming one.
return now >= addDays(monday, 5) ? addDays(monday, 7) : monday;
}
/** End of the busy stretch running at `now` (back-to-back events chained). */
function busyUntil(events: EventItem[], now: number): number {
const timed = eventsOnDay(events, startOfDay(now)).filter((e) => !e.allDay);
let end = now;
let grew = true;
while (grew) {
grew = false;
for (const e of timed) {
if (e.start <= end && e.end > end) {
end = e.end;
grew = true;
}
}
}
return end;
}
interface BarSegment {
width: number;
color?: string;
}
/**
* Busy blocks as a left-to-right run of spacers and coloured segments in dp,
* so the bar is a plain LinearLayout. Overlapping events continue in the later
* event's colour; very short ones keep 2dp so they stay visible.
*/
function barSegments(blocks: BusyBlock[], trackWidth: number): BarSegment[] {
const out: BarSegment[] = [];
let cursor = 0;
for (const b of [...blocks].sort((x, y) => x.from - y.from)) {
const x0 = Math.max(cursor, Math.round(b.from * trackWidth));
let x1 = Math.min(trackWidth, Math.round(b.to * trackWidth));
if (x1 - x0 < 2) x1 = Math.min(trackWidth, x0 + 2);
if (x1 <= x0) continue;
if (x0 > cursor) out.push({ width: x0 - cursor });
out.push({ width: x1 - x0, color: b.color });
cursor = x1;
}
return out;
}
/** Free stretches of one day inside working hours, from the busy-block gaps ("10:00–12:00"). */
function freeSlots(f: Fmt, events: EventItem[], dayStart: number, now: number): string[] {
const winStart = dayStart + WORK_START * HOUR;
const winEnd = dayStart + WORK_END * HOUR;
const span = winEnd - winStart;
const quarter = 15 * MINUTE;
// Fractions back to clock times, snapped to the minute so float error never shows as 09:59.
const toMs = (frac: number) => Math.round((winStart + frac * span) / MINUTE) * MINUTE;
const busy = busyBlocks(events, dayStart, WORK_START, WORK_END)
.map((b) => ({ from: toMs(b.from), to: toMs(b.to) }))
.sort((a, b) => a.from - b.from);
const slots: string[] = [];
const push = (from: number, to: number) => {
if (to - from >= quarter) slots.push(`${f.time(from)}–${f.time(to)}`);
};
// Today starts at the next quarter hour; past days produce nothing.
let cursor = Math.max(winStart, Math.ceil(now / quarter) * quarter);
if (cursor >= winEnd) return slots;
for (const b of busy) {
if (b.from > cursor) push(cursor, Math.min(b.from, winEnd));
cursor = Math.max(cursor, b.to);
if (cursor >= winEnd) return slots;
}
push(cursor, winEnd);
return slots;
}
/** "8:00" / "8 AM" for the axis under the bars. */
function hourLabel(hour: number, hour12: boolean): string {
if (!hour12) return `${hour}:00`;
return `${hour % 12 === 0 ? 12 : hour % 12} ${hour < 12 ? 'AM' : 'PM'}`;
}
interface FreeTier {
/** Vertical padding. */
pad: number;
gap: number;
rowHeight: number;
axis: boolean;
/** Button height; 0 hides the button. */
button: number;
}
const FREE_HEADLINE_HEIGHT = 22;
const FREE_AXIS_HEIGHT = 13;
const FREE_LABEL_WIDTH = 28;
const FREE_LABEL_GAP = 8;
/** Roomiest first; the layout takes the first one that fits the widget height. */
const FREE_TIERS: FreeTier[] = [
{ pad: 10, gap: 6, rowHeight: 13, axis: true, button: 32 },
{ pad: 8, gap: 3, rowHeight: 12, axis: true, button: 30 },
{ pad: 8, gap: 3, rowHeight: 12, axis: false, button: 30 },
{ pad: 8, gap: 3, rowHeight: 11, axis: false, button: 28 },
{ pad: 8, gap: 3, rowHeight: 11, axis: false, button: 0 },
];
function freeTierHeight(t: FreeTier): number {
return (
2 +
t.pad * 2 +
FREE_HEADLINE_HEIGHT +
t.gap +
5 * t.rowHeight +
(t.axis ? FREE_AXIS_HEIGHT : 0) +
(t.button ? t.gap + t.button : 0)
);
}
export const FreeTimeLayout: Layout = ({ s, p, f, now, width, height }) => {
if (!s.calendar.supported) {
return (
<Surface p={p}>
<Placeholder p={p} iconName="calendar" title={f.t('widgets.free.no_calendar', 'No calendar on this account')} />
</Surface>
);
}
// A declined invitation is not busy time.
const events = s.calendar.events.filter((e) => e.myStatus !== 'declined');
const tier = FREE_TIERS.find((t) => freeTierHeight(t) <= height) ?? FREE_TIERS[FREE_TIERS.length - 1];
const ft = freeTime(events, now, WORK_START, WORK_END);
let headline: string;
let sub: string | null = null;
if (ft.busyNow) {
const until = busyUntil(events, now);
headline = f.t('widgets.free.busy_until', 'Busy until {time}', { time: f.time(until) });
const after = eventsOnDay(events, startOfDay(now)).find((e) => !e.allDay && e.start >= until);
sub = after
? f.t('widgets.free.then', 'then {title}', { title: untitled(f, after.title) })
: f.t('widgets.free.then_free', 'then free');
} else if (ft.next) {
headline = f.t('widgets.free.free_until', 'Free until {time}', { time: f.time(ft.next.start) });
sub = f.t('widgets.free.then', 'then {title}', { title: untitled(f, ft.next.title) });
} else {
headline = f.t('widgets.free.free_rest', 'Free for the rest of the day');
}
const trackWidth = Math.max(40, Math.floor(width - 2 - 2 * 14 - FREE_LABEL_WIDTH - FREE_LABEL_GAP));
const today = startOfDay(now);
const monday = barsMonday(now, s.weekStart);
const days = [0, 1, 2, 3, 4].map((i) => addDays(monday, i));
const labelSize = tier.rowHeight >= 13 ? 11 : 10;
const bodyLines = days
.map((day) => {
const slots = freeSlots(f, events, day, now);
return slots.length > 0 ? `${f.weekdayDayMonth(day)}: ${slots.join(', ')}` : null;
})
.filter((line): line is string => line !== null);
const mail = open(
links.compose({
subject: f.t('widgets.free.mail_subject', 'My free times this week'),
body: bodyLines.length > 0 ? bodyLines.join('\n') : undefined,
}),
);
const track = (day: number) => {
const segments = barSegments(busyBlocks(events, day, WORK_START, WORK_END), trackWidth);
return (
<FlexWidget style={{ width: trackWidth, height: tier.rowHeight, justifyContent: 'center' }}>
<FlexWidget
style={{
flexDirection: 'row',
width: trackWidth,
height: 8,
borderRadius: 4,
backgroundColor: p.mutedBg,
overflow: 'hidden',
}}
>
{segments.map((seg, i) => (
<FlexWidget
key={i}
style={{ width: seg.width, height: 8, ...(seg.color ? { backgroundColor: normalizeHex(seg.color) } : {}) }}
/>
))}
</FlexWidget>
</FlexWidget>
);
};
return (
<Surface p={p} style={{ paddingHorizontal: 14, paddingVertical: tier.pad }}>
<FlexWidget
{...open(links.calendar())}
style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent', height: FREE_HEADLINE_HEIGHT }}
>
<Txt text={headline} color={p.fg} size={16} weight="600" />
{sub ? <Spacer size={8} horizontal /> : null}
{sub ? (
<FlexWidget style={{ flex: 1 }}>
<Txt text={sub} color={p.muted} size={12} />
</FlexWidget>
) : null}
</FlexWidget>
<Spacer size={tier.gap} />
<FlexWidget {...open(links.calendar())} style={{ width: 'match_parent' }}>
{days.map((day) => {
const isToday = day === today;
const nowFraction = (now - (day + WORK_START * HOUR)) / ((WORK_END - WORK_START) * HOUR);
const marker = isToday && nowFraction >= 0 && nowFraction <= 1
? Math.max(0, Math.min(trackWidth - 2, Math.round(nowFraction * trackWidth) - 1))
: null;
return (
<FlexWidget
key={day}
style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent', height: tier.rowHeight }}
>
<FlexWidget style={{ width: FREE_LABEL_WIDTH, height: tier.rowHeight, justifyContent: 'center' }}>
<Txt
text={f.weekdayShort(day)}
color={isToday ? p.fg : p.muted}
size={labelSize}
weight={isToday ? '600' : '400'}
/>
</FlexWidget>
<Spacer size={FREE_LABEL_GAP} horizontal />
{marker === null ? (
track(day)
) : (
<OverlapWidget style={{ width: trackWidth, height: tier.rowHeight }}>
{track(day)}
<FlexWidget style={{ flexDirection: 'row', width: trackWidth, height: tier.rowHeight }}>
<FlexWidget style={{ width: marker, height: 1 }} />
<FlexWidget style={{ width: 2, height: tier.rowHeight, backgroundColor: p.destructive }} />
</FlexWidget>
</OverlapWidget>
)}
</FlexWidget>
);
})}
{tier.axis ? (
<FlexWidget
style={{
flexDirection: 'row',
alignItems: 'center',
width: 'match_parent',
height: FREE_AXIS_HEIGHT,
paddingLeft: FREE_LABEL_WIDTH + FREE_LABEL_GAP,
}}
>
<Txt text={hourLabel(WORK_START, f.hour12)} color={p.muted} size={10} />
<Spacer />
<Txt text={hourLabel(Math.round((WORK_START + WORK_END) / 2), f.hour12)} color={p.muted} size={10} />
<Spacer />
<Txt text={hourLabel(WORK_END, f.hour12)} color={p.muted} size={10} />
</FlexWidget>
) : null}
</FlexWidget>
{tier.button ? <Spacer size={tier.gap} /> : null}
<Spacer />
{tier.button ? (
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
<Button
p={p}
iconName="mail"
label={f.t('widgets.free.mail', 'Mail my free times')}
flex={1}
height={tier.button}
click={mail}
/>
</FlexWidget>
) : null}
</Surface>
);
};
// ── Tasks ───────────────────────────────────────────────────────────────────
function tasksOff(p: WidgetPalette, f: Fmt) {
return (
<Surface p={p}>
<Placeholder
p={p}
iconName="checklist"
title={f.t('widgets.tasks.off', 'Tasks are off')}
body={f.t('widgets.tasks.off_body', "Turn on tasks in the app's calendar settings")}
click={open(links.settings('calendar'))}
/>
</Surface>
);
}
export const TasksLayout: Layout = ({ s, p, f, now, height }) => {
if (!s.tasks.supported) return tasksOff(p, f);
const compact = height < 200;
const headerHeight = compact ? 36 : 44;
// Open tasks first; sortTasks puts the completed ones last, so they only fill leftover rows.
const sorted = sortTasks(s.tasks.items, now);
const openCount = sorted.filter((t) => !t.done).length;
const fit = Math.max(1, Math.floor((height - 2 - headerHeight - 1) / TASK_ROW_HEIGHT));
const rows = sorted.slice(0, fit);
return (
<Surface p={p}>
<Header
p={p}
height={headerHeight}
iconName="checklist"
title={f.t('widgets.tasks.title', 'Tasks')}
count={f.t('widgets.tasks.open_count', '{count} open', { count: openCount })}
click={open(links.calendar())}
trailing={<Fab p={p} iconName="plus" size={compact ? 28 : 32} click={open(links.calendar())} />}
/>
{openCount === 0 ? (
<Placeholder p={p} iconName="check" title={f.t('widgets.tasks.empty', 'No open tasks')} click={open(links.calendar())} />
) : (
rows.map((task, i) => <TaskRow key={task.id} p={p} f={f} task={task} now={now} last={i === rows.length - 1} />)
)}
</Surface>
);
};
export const TaskProgressLayout: Layout = ({ s, p, f, now, width, height }) => {
if (!s.tasks.supported) return tasksOff(p, f);
const items = s.tasks.items;
// Today's work: what is overdue or due today, plus what has been ticked off.
const counted = new Map<string, TaskItem>();
for (const t of tasksDueNow(items, now)) counted.set(t.id, t);
for (const t of items) if (t.done) counted.set(t.id, t);
const set = counted.size > 0 ? Array.from(counted.values()) : items;
if (set.length === 0) {
return (
<Surface p={p}>
<Placeholder p={p} iconName="check" title={f.t('widgets.tasks.empty', 'No open tasks')} click={open(links.calendar())} />
</Surface>
);
}
const total = set.length;
const done = set.filter((t) => t.done).length;
const left = total - done;
const pad = height < 130 ? 10 : height < 150 ? 12 : 14;
// Title (~19dp) and status line (~16dp) plus two 6dp gaps around the ring.
const ringSize = Math.max(
32,
Math.min(80, Math.floor(height - 2 - 2 * pad - 19 - 16 - 12), Math.floor(width - 2 - 2 * pad)),
);
const labelSize = ringSize >= 72 ? 20 : ringSize >= 60 ? 17 : ringSize >= 48 ? 14 : 11;
return (
<Surface p={p} style={{ padding: pad, alignItems: 'center' }} click={open(links.calendar())}>
<FlexWidget style={{ width: 'match_parent' }}>
<Txt text={f.t('widgets.tasks.title', 'Tasks')} color={p.fg} size={14} weight="500" />
</FlexWidget>
<Spacer size={6} />
<OverlapWidget style={{ width: ringSize, height: ringSize }}>
<SvgWidget svg={ring(done / total, p.mutedBg, p.success)} style={{ width: ringSize, height: ringSize }} />
<FlexWidget style={{ width: ringSize, height: ringSize, justifyContent: 'center', alignItems: 'center' }}>
<Txt text={`${done}/${total}`} color={p.fg} size={labelSize} weight="700" />
</FlexWidget>
</OverlapWidget>
<Spacer size={6} />
<Txt
text={left > 0 ? f.t('widgets.tasks.still_open', '{count} still open', { count: left }) : f.t('widgets.tasks.all_done', 'All done')}
color={p.muted}
size={12}
/>
</Surface>
);
};
// ── Countdown ───────────────────────────────────────────────────────────────
export const CountdownLayout: Layout = ({ s, p, f, now, height }) => {
const e = countdownTarget(s.calendar.events, now);
if (!e) {
return (
<Surface p={p}>
<Placeholder p={p} iconName="calendar" title={f.t('widgets.countdown.empty', 'No upcoming events')} click={open(links.calendar())} />
</Surface>
);
}
const days = Math.max(0, daysUntil(e.start, now));
// ~133dp tall at 48sp; smaller launchers get a smaller number and padding.
const bigSize = height >= 140 ? 48 : height >= 125 ? 40 : 34;
const pad = height >= 125 ? 14 : 10;
const word = days === 0 ? f.t('widgets.calendar.today', 'Today') : days === 1 ? f.t('widgets.calendar.tomorrow', 'Tomorrow') : null;
const when = `${f.weekdayDayMonth(e.start)} · ${e.allDay ? f.t('widgets.calendar.all_day', 'All day') : f.time(e.start)}`;
return (
<Surface p={p} style={{ padding: pad }} click={open(links.event(e))}>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent' }}>
<Dot color={normalizeHex(e.color)} size={8} />
<Spacer size={6} horizontal />
<FlexWidget style={{ flex: 1 }}>
<Txt text={untitled(f, e.title)} color={p.fg} size={14} weight="500" />
</FlexWidget>
</FlexWidget>
<Spacer />
{word ? (
<Txt text={word} color={p.fg} size={Math.round(bigSize * 0.55)} weight="700" />
) : (
<FlexWidget style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
<Txt text={String(days)} color={p.fg} size={bigSize} weight="700" />
<Spacer size={6} horizontal />
{/* Bottom padding lines the unit up with the number's baseline (descent is ~0.27em). */}
<Txt
text={f.t('widgets.countdown.days', '{count, plural, one {day} other {days}}', { count: days })}
color={p.muted}
size={14}
style={{ paddingBottom: Math.round((bigSize - 14) * 0.27) }}
/>
</FlexWidget>
)}
<Txt text={when} color={p.muted} size={12} style={{ marginTop: 4 }} />
</Surface>
);
};
// ── Birthdays ───────────────────────────────────────────────────────────────
const BIRTHDAY_HEADER_HEIGHT = 38;
const BIRTHDAY_ROW_HEIGHT = 50;
export const BirthdaysLayout: Layout = ({ s, p, f, now, width, height }) => {
const upcoming = upcomingBirthdays(s, now);
const header = (
<Header
p={p}
height={BIRTHDAY_HEADER_HEIGHT}
iconName="cake"
iconColor={BIRTHDAY_COLOR}
title={f.t('widgets.birthdays.title', 'Birthdays')}
click={open(links.calendar())}
/>
);
if (upcoming.length === 0) {
return (
<Surface p={p}>
{header}
<Placeholder
p={p}
iconName="cake"
title={f.t('widgets.birthdays.empty', 'No birthdays coming up')}
body={f.t('widgets.birthdays.empty_body', 'Birthdays come from your contacts.')}
click={open(links.contacts())}
/>
</Surface>
);
}
const fit = Math.max(1, Math.floor((height - 2 - BIRTHDAY_HEADER_HEIGHT - 1) / BIRTHDAY_ROW_HEIGHT));
const rows = upcoming.slice(0, fit);
const wishTarget = rows.find((b) => b.email);
// At 3 columns the labelled button would leave no room for the name.
const narrow = width < 260;
return (
<Surface p={p}>
{header}
{rows.map((b, i) => {
const days = daysUntil(b.date, now);
const dateLine = days <= 0
? f.t('widgets.calendar.today', 'Today')
: days === 1
? f.t('widgets.calendar.tomorrow', 'Tomorrow')
: `${f.weekdayDayMonth(b.date)} · ${f.t('widgets.birthdays.in_days', '{count, plural, one {in # day} other {in # days}}', { count: days })}`;
const wish = b === wishTarget && b.email;
return (
<FlexWidget
key={`${b.name}-${b.date}`}
{...open(links.contacts())}
style={{
flexDirection: 'row',
alignItems: 'center',
width: 'match_parent',
height: BIRTHDAY_ROW_HEIGHT,
paddingHorizontal: 14,
borderBottomWidth: i === rows.length - 1 ? 0 : 1,
borderBottomColor: p.border,
}}
>
<Avatar initials={b.initials} color={b.color} size={32} />
<Spacer size={10} horizontal />
<FlexWidget style={{ flex: 1 }}>
<Txt text={b.name || b.email || ''} color={p.fg} size={14} weight="600" />
<Txt text={dateLine} color={p.muted} size={12} />
</FlexWidget>
{wish ? <Spacer size={8} horizontal /> : null}
{wish ? (
<Button
p={p}
variant="primary"
iconName="send"
label={narrow ? undefined : f.t('widgets.birthdays.send_wishes', 'Send wishes')}
click={open(links.compose({ to: wish, subject: f.t('widgets.birthdays.wishes_subject', 'Happy birthday!') }))}
/>
) : null}
</FlexWidget>
);
})}
</Surface>
);
};
+21
View File
@@ -0,0 +1,21 @@
import type React from 'react';
import type { Fmt } from '../format';
import type { WidgetSnapshot } from '../snapshot';
import type { WidgetPalette } from '../theme';
/** Per-widget state kept between draws (e.g. which message the triage card shows). */
export type WidgetLocalState = Record<string, unknown>;
export interface LayoutProps {
s: WidgetSnapshot;
p: WidgetPalette;
f: Fmt;
now: number;
/** Widget size in dp as the launcher reports it. */
width: number;
height: number;
widgetId: number;
local: WidgetLocalState;
}
export type Layout = (props: LayoutProps) => React.JSX.Element;
+26
View File
@@ -0,0 +1,26 @@
// State that belongs to one placed widget rather than to the data: which
// message the triage card is showing. Keyed by the launcher's widget id and
// dropped when the widget is removed.
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { WidgetLocalState } from './layouts/types';
const key = (widgetId: number) => `widgets:local:${widgetId}`;
export async function loadLocal(widgetId: number): Promise<WidgetLocalState> {
try {
const raw = await AsyncStorage.getItem(key(widgetId));
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' ? (parsed as WidgetLocalState) : {};
} catch {
return {};
}
}
export async function saveLocal(widgetId: number, state: WidgetLocalState): Promise<void> {
await AsyncStorage.setItem(key(widgetId), JSON.stringify(state));
}
export async function removeLocal(widgetId: number): Promise<void> {
await AsyncStorage.removeItem(key(widgetId));
}
+276
View File
@@ -0,0 +1,276 @@
// List rows reused across widgets: the message rows of the webmail list, the
// agenda's event rows and day bands, and the task list's rows.
import React from 'react';
import { FlexWidget, TextWidget } from 'react-native-android-widget';
import { action, links, open, type WidgetClick } from './clicks';
import { dueKind } from './derive';
import type { Fmt } from './format';
import { Avatar, ColorBar, Dot, FilledIcon, Icon, Spacer, Txt } from './primitives';
import type { EventItem, MailItem, TaskItem } from './snapshot';
import type { WidgetPalette } from './theme';
/** `bg-primary` pill with the message icon and the thread size, as in the list. */
export function ThreadBadge({ p, count }: { p: WidgetPalette; count: number }) {
return (
<FlexWidget
style={{
flexDirection: 'row',
alignItems: 'center',
height: 18,
paddingHorizontal: 6,
borderRadius: 999,
backgroundColor: p.primary,
}}
>
<Icon name="message" color={p.primaryFg} size={11} />
<Spacer size={3} horizontal />
<TextWidget text={String(count)} style={{ color: p.primaryFg, fontSize: 11, fontWeight: '600' }} />
</FlexWidget>
);
}
/**
* The webmail's message row. `compact` drops the avatar and preview (the
* extra-compact density); otherwise avatar, sender/time, subject and one line
* of preview. Unread rows get the accent tint and the unread dot.
*/
export function MailRow({
p,
f,
m,
now,
compact,
showAccountDot,
accountColor,
last,
}: {
p: WidgetPalette;
f: Fmt;
m: MailItem;
now: number;
compact?: boolean;
showAccountDot?: boolean;
accountColor?: string;
last?: boolean;
}) {
const click = open(links.message(m));
const senderColor = m.unread ? p.fg : p.muted;
const header = (
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent', height: 18 }}>
{showAccountDot && accountColor ? <Dot color={accountColor as `#${string}`} size={8} /> : null}
{showAccountDot && accountColor ? <Spacer size={6} horizontal /> : null}
<FlexWidget style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
<Txt text={m.fromName || m.fromEmail} color={senderColor} size={14} weight={m.unread ? '700' : '500'} />
{!compact && m.threadSize > 1 ? <Spacer size={6} horizontal /> : null}
{!compact && m.threadSize > 1 ? <ThreadBadge p={p} count={m.threadSize} /> : null}
{!compact && m.hasAttachment ? <Spacer size={4} horizontal /> : null}
{!compact && m.hasAttachment ? <Icon name="paperclip" color={p.muted} size={13} /> : null}
{m.starred ? <Spacer size={4} horizontal /> : null}
{m.starred ? <FilledIcon name="star" color={p.star} size={13} /> : null}
</FlexWidget>
<Spacer size={6} horizontal />
<Txt text={f.listDate(m.receivedAt, now)} color={m.unread ? p.fg : p.muted} size={12} weight={m.unread ? '600' : '400'} />
</FlexWidget>
);
const subject = (
<Txt
text={m.subject || f.t('widgets.mail.no_subject', '(no subject)')}
color={m.unread ? p.fg : p.subjectRead}
size={compact ? 13 : 14}
weight={m.unread ? '600' : '400'}
/>
);
return (
<FlexWidget
{...click}
style={{
width: 'match_parent',
flexDirection: 'row',
backgroundColor: m.unread ? p.unreadRow : p.bg,
borderBottomWidth: last ? 0 : 1,
borderBottomColor: p.border,
paddingLeft: 4,
paddingRight: 12,
paddingVertical: compact ? 4 : 8,
}}
>
<FlexWidget style={{ width: 12, paddingTop: 5, alignItems: 'center' }}>
{m.unread ? <Dot color={p.unreadDot} size={7} /> : null}
</FlexWidget>
{!compact ? <Avatar initials={m.initials} color={m.color} size={32} /> : null}
{!compact ? <Spacer size={10} horizontal /> : null}
<FlexWidget style={{ flex: 1 }}>
{header}
{subject}
{!compact && m.preview ? <Txt text={m.preview} color={p.muted} size={13} /> : null}
</FlexWidget>
</FlexWidget>
);
}
/** Row height used to decide how many rows fit. */
export const MAIL_ROW_HEIGHT = { compact: 45, full: 76 };
/** The agenda's day band: "Today" in the accent, other days in the foreground. */
export function DayBand({ p, f, dayStart, now }: { p: WidgetPalette; f: Fmt; dayStart: number; now: number }) {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const diff = Math.round((dayStart - today.getTime()) / 86400000);
const label = diff === 0
? f.t('widgets.calendar.today', 'Today')
: diff === 1
? f.t('widgets.calendar.tomorrow', 'Tomorrow')
: f.weekdayLong(dayStart);
return (
<FlexWidget
style={{
flexDirection: 'row',
alignItems: 'center',
width: 'match_parent',
height: 28,
paddingHorizontal: 14,
backgroundColor: p.dayHeader,
borderBottomWidth: 1,
borderBottomColor: p.border,
}}
>
<Txt text={label} color={diff === 0 ? p.today : p.fg} size={13} weight="600" />
<Spacer size={8} horizontal />
<Txt text={f.dayMonth(dayStart)} color={p.muted} size={12} />
</FlexWidget>
);
}
/** Agenda event row: start/end column, colour bar, title and place. */
export function AgendaRow({ p, f, e, last }: { p: WidgetPalette; f: Fmt; e: EventItem; last?: boolean }) {
const meta = e.videoUrl
? { icon: 'video' as const, text: e.videoName || f.t('widgets.calendar.video_call', 'Video call') }
: e.location
? { icon: 'mapPin' as const, text: e.location }
: null;
const sub = [meta?.text, e.calendarName].filter(Boolean).join(' · ');
return (
<FlexWidget
{...open(links.event(e))}
style={{
flexDirection: 'row',
width: 'match_parent',
paddingHorizontal: 14,
paddingVertical: 7,
borderBottomWidth: last ? 0 : 1,
borderBottomColor: p.border,
}}
>
<FlexWidget style={{ width: 44, alignItems: 'center' }}>
{e.allDay ? (
<Txt text={f.t('widgets.calendar.all_day', 'All day')} color={p.muted} size={12} />
) : (
<FlexWidget style={{ alignItems: 'center' }}>
<Txt text={f.time(e.start)} color={p.fg} size={13} weight="600" />
<Txt text={f.time(e.end)} color={p.muted} size={11} />
</FlexWidget>
)}
</FlexWidget>
<Spacer size={8} horizontal />
<ColorBar color={e.color} height={34} />
<Spacer size={10} horizontal />
<FlexWidget style={{ flex: 1 }}>
<Txt text={e.title || f.t('widgets.calendar.untitled', '(no title)')} color={p.fg} size={14} weight="500" />
{sub ? (
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
{meta ? <Icon name={meta.icon} color={p.muted} size={12} /> : null}
{meta ? <Spacer size={4} horizontal /> : null}
<Txt text={sub} color={p.muted} size={12} />
</FlexWidget>
) : null}
</FlexWidget>
</FlexWidget>
);
}
export const AGENDA_ROW_HEIGHT = 50;
export const DAY_BAND_HEIGHT = 28;
/** Task list row: round tick box that works in place, title, due label, calendar. */
export function TaskRow({
p,
f,
task,
now,
last,
}: {
p: WidgetPalette;
f: Fmt;
task: TaskItem;
now: number;
last?: boolean;
}) {
const kind = dueKind(task, now);
const dueColor = task.done
? p.muted
: kind === 'overdue'
? p.overdue
: kind === 'today'
? p.dueToday
: p.muted;
const dueLabel = task.due === undefined
? ''
: kind === 'overdue'
? f.t('widgets.tasks.overdue', 'Overdue')
: kind === 'today'
? f.t('widgets.tasks.today', 'Today')
: kind === 'tomorrow'
? f.t('widgets.tasks.tomorrow', 'Tomorrow')
: f.weekdayDayMonth(task.due);
const toggle: WidgetClick = action('toggleTask', { id: task.id });
return (
<FlexWidget
style={{
flexDirection: 'row',
width: 'match_parent',
paddingHorizontal: 14,
paddingVertical: 8,
borderBottomWidth: last ? 0 : 1,
borderBottomColor: p.border,
}}
>
<FlexWidget
{...toggle}
accessibilityLabel={task.done ? f.t('widgets.tasks.mark_incomplete', 'Mark as not done') : f.t('widgets.tasks.mark_complete', 'Mark as done')}
style={{
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: task.done ? p.success : (`${p.muted}66` as `#${string}`),
backgroundColor: task.done ? p.success : p.bg,
justifyContent: 'center',
alignItems: 'center',
}}
>
{task.done ? <Icon name="check" color="#ffffff" size={13} /> : null}
</FlexWidget>
<Spacer size={12} horizontal />
<FlexWidget style={{ flex: 1 }}>
<TextWidget
text={task.title || f.t('widgets.tasks.no_title', '(no title)')}
maxLines={1}
truncate="END"
style={{ color: task.done ? p.muted : p.fg, fontSize: 14, fontWeight: '500' }}
/>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
{dueLabel ? <Icon name="calendarPlain" color={dueColor} size={12} /> : null}
{dueLabel ? <Spacer size={4} horizontal /> : null}
{dueLabel ? <Txt text={dueLabel} color={dueColor} size={12} /> : null}
{dueLabel && task.calendarName ? <Spacer size={8} horizontal /> : null}
{task.calendarName ? <Dot color={task.color as `#${string}`} size={8} /> : null}
{task.calendarName ? <Spacer size={4} horizontal /> : null}
{task.calendarName ? <Txt text={task.calendarName} color={p.muted} size={12} /> : null}
</FlexWidget>
</FlexWidget>
</FlexWidget>
);
}
export const TASK_ROW_HEIGHT = 55;
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Some files were not shown because too many files have changed in this diff Show More