feat: add android home-screen widgets for mail, calendar, tasks and files
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
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>
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "\\'");
|
||||
const resName = (name) => `widgetprovider_${name.toLowerCase()}`;
|
||||
|
||||
const hasPreview = (w) => existsSync(join(PREVIEWS, `${w.name}.png`));
|
||||
const previewRes = (w) => `${w.name.toLowerCase()}_preview`;
|
||||
|
||||
function providerXml(w) {
|
||||
const preview = hasPreview(w) ? `\n android:previewImage="@drawable/${previewRes(w)}"` : '';
|
||||
return `<?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.`);
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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)}` : ''}`,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>`
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
After Width: | Height: | Size: 38 KiB |