mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 16:07:12 +00:00
feat: add an iOS inbox widget
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
// The iOS inbox widget (WidgetKit, plugins/ios-widget). It can't run JS: the
|
||||
// app writes it a finished snapshot into the keychain group it shares with
|
||||
// its extensions and asks WidgetKit to redraw.
|
||||
|
||||
const rn = vi.hoisted(() => ({
|
||||
Platform: { OS: 'ios' },
|
||||
AppState: { addEventListener: () => ({ remove: () => undefined }) },
|
||||
NativeModules: {} as Record<string, unknown>,
|
||||
}));
|
||||
vi.mock('react-native', () => rn);
|
||||
vi.mock('react-native-android-widget', () => ({
|
||||
FlexWidget: () => null,
|
||||
OverlapWidget: () => null,
|
||||
SvgWidget: () => null,
|
||||
TextWidget: () => null,
|
||||
ImageWidget: () => null,
|
||||
getWidgetInfo: vi.fn(async () => []),
|
||||
requestWidgetUpdate: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock('../../lib/ios-push-shared', () => ({
|
||||
sharedStoreOptions: () => ({ accessGroup: 'TEAM.org.bulwarkmail.shared', keychainAccessible: 1 }),
|
||||
}));
|
||||
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { IOS_WIDGET_KEY, iosWidgetSnapshot } from '../ios';
|
||||
import { redrawAll, hasPlacedWidgets, invalidatePlacedCache } from '../render';
|
||||
import { handleAppState } from '../sync';
|
||||
import { saveSnapshot } from '../snapshot';
|
||||
import { links } from '../clicks';
|
||||
import { PREVIEW_NOW, sampleSnapshot } from '../sample-snapshot';
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../../..');
|
||||
const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8');
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
describe('the snapshot the iOS widget draws', () => {
|
||||
const s = sampleSnapshot(PREVIEW_NOW);
|
||||
|
||||
it('carries the inbox unread count and the newest messages with their deep links', () => {
|
||||
const out = iosWidgetSnapshot(s);
|
||||
const inbox = s.mail.folders.find((f) => f.role === 'inbox');
|
||||
expect(out.unread).toBe(inbox?.unread);
|
||||
expect(out.messages.length).toBe(Math.min(6, s.mail.inbox.length));
|
||||
expect(out.messages[0].id).toBe(s.mail.inbox[0].id);
|
||||
expect(out.messages[0].url).toBe(links.message(s.mail.inbox[0]));
|
||||
expect(out.messages[0].url.startsWith('bulwarkmobile://mail/message/')).toBe(true);
|
||||
expect(out.inboxUrl).toBe('bulwarkmobile://mail/folder/inbox');
|
||||
expect(out.strings.inbox).toBe('Inbox');
|
||||
});
|
||||
|
||||
it('names the account only when there is more than one', () => {
|
||||
expect(iosWidgetSnapshot({ ...s, accounts: s.accounts.slice(0, 1) }).account).toBeNull();
|
||||
const two = [{ id: 'a', label: 'ada@example.org', color: '#000000', unread: 1 }, { id: 'b', label: 'bea@example.org', color: '#000000', unread: 0 }];
|
||||
expect(iosWidgetSnapshot({ ...s, accounts: two, activeAccountId: 'b' }).account).toBe('bea@example.org');
|
||||
});
|
||||
|
||||
it('fills in a missing subject', () => {
|
||||
const blank = { ...s, mail: { ...s.mail, inbox: [{ ...s.mail.inbox[0], subject: '' }] } };
|
||||
expect(iosWidgetSnapshot(blank).messages[0].subject).toBe('(no subject)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the app hands the widget its data on iOS', () => {
|
||||
const reloadAll = vi.fn(async () => undefined);
|
||||
const placedCount = vi.fn(async () => 1);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.mocked(SecureStore.setItemAsync).mockClear();
|
||||
reloadAll.mockClear();
|
||||
rn.NativeModules.BulwarkWidgetKit = { reloadAll, placedCount };
|
||||
invalidatePlacedCache();
|
||||
await saveSnapshot(sampleSnapshot(PREVIEW_NOW));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete rn.NativeModules.BulwarkWidgetKit;
|
||||
});
|
||||
|
||||
it('writes the snapshot to the shared keychain group and reloads the widget', async () => {
|
||||
await redrawAll();
|
||||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
|
||||
IOS_WIDGET_KEY,
|
||||
expect.any(String),
|
||||
expect.objectContaining({ accessGroup: 'TEAM.org.bulwarkmail.shared' }),
|
||||
);
|
||||
const written = JSON.parse(vi.mocked(SecureStore.setItemAsync).mock.calls[0][1] as string);
|
||||
expect(written.messages.length).toBeGreaterThan(0);
|
||||
expect(reloadAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('asks WidgetKit whether a widget is placed', async () => {
|
||||
expect(await hasPlacedWidgets()).toBe(true);
|
||||
placedCount.mockResolvedValueOnce(0);
|
||||
invalidatePlacedCache();
|
||||
expect(await hasPlacedWidgets()).toBe(false);
|
||||
});
|
||||
|
||||
it('hands the widget what the app has when it goes to the background', async () => {
|
||||
handleAppState('background');
|
||||
await flush();
|
||||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(IOS_WIDGET_KEY, expect.any(String), expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('the WidgetKit side', () => {
|
||||
const swift = read('plugins/ios-widget/BulwarkWidget.swift');
|
||||
const kit = read('plugins/ios-widget/BulwarkWidgetKit.swift');
|
||||
const bridge = read('plugins/ios-widget/BulwarkWidgetKit.m');
|
||||
const plugin = createRequire(__filename)(path.join(ROOT, 'plugins/ios-widget/index.js')) as {
|
||||
internals: {
|
||||
includeWidget(o: { appProfile: string; widgetProfile: string }): boolean;
|
||||
infoPlist(o: { version: string; buildNumber: string }): string;
|
||||
entitlementsPlist(): string;
|
||||
SOURCE_FILES: string[];
|
||||
APP_FILES: string[];
|
||||
};
|
||||
};
|
||||
|
||||
it('reads the key the app writes, with the fields it writes', () => {
|
||||
expect(swift).toContain(`SharedKeychain.read("${IOS_WIDGET_KEY}")`);
|
||||
const fields = Object.keys(iosWidgetSnapshot(sampleSnapshot(PREVIEW_NOW)));
|
||||
for (const field of fields) expect(swift).toMatch(new RegExp(`let ${field}: `));
|
||||
for (const field of Object.keys(iosWidgetSnapshot(sampleSnapshot(PREVIEW_NOW)).messages[0])) {
|
||||
expect(swift).toMatch(new RegExp(`let ${field}: `));
|
||||
}
|
||||
});
|
||||
|
||||
it('opens a tapped message through its deep link', () => {
|
||||
expect(swift).toContain('Link(destination: url)');
|
||||
expect(swift).toContain('.widgetURL(');
|
||||
});
|
||||
|
||||
it('reloads the widget kind the extension declares', () => {
|
||||
expect(swift).toContain('let inboxWidgetKind = "BulwarkInbox"');
|
||||
expect(kit).toContain('static let inboxKind = "BulwarkInbox"');
|
||||
expect(bridge).toContain('RCT_EXTERN_MODULE(BulwarkWidgetKit, NSObject)');
|
||||
expect(bridge).toContain('RCT_EXTERN_METHOD(reloadAll:');
|
||||
expect(bridge).toContain('RCT_EXTERN_METHOD(placedCount:');
|
||||
});
|
||||
|
||||
it('builds a WidgetKit extension in the shared keychain group', () => {
|
||||
const { internals } = plugin;
|
||||
expect(internals.infoPlist({ version: '1.0.0', buildNumber: '7' })).toContain('<string>com.apple.widgetkit-extension</string>');
|
||||
expect(internals.entitlementsPlist()).toContain('$(AppIdentifierPrefix)org.bulwarkmail.shared');
|
||||
expect(internals.SOURCE_FILES).toEqual(['BulwarkWidget.swift', 'SharedKeychain.swift']);
|
||||
expect(internals.APP_FILES).toEqual(['BulwarkWidgetKit.swift', 'BulwarkWidgetKit.m']);
|
||||
expect(read('app.config.js')).toContain("'./plugins/ios-widget'");
|
||||
});
|
||||
|
||||
it('leaves the widget out of a signed build that has no profile for it', () => {
|
||||
const { includeWidget } = plugin.internals;
|
||||
expect(includeWidget({ appProfile: '', widgetProfile: '' })).toBe(true);
|
||||
expect(includeWidget({ appProfile: 'app-uuid', widgetProfile: 'widget-uuid' })).toBe(true);
|
||||
expect(includeWidget({ appProfile: 'app-uuid', widgetProfile: '' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// The iOS inbox widget (WidgetKit, plugins/ios-widget). It can't run JS, so
|
||||
// the app hands it a small finished snapshot: the active account's inbox
|
||||
// unread count and newest messages, cut from the data the Android widgets
|
||||
// draw (./snapshot.ts), with the texts already translated and each message's
|
||||
// deep link (./clicks.ts) ready to open.
|
||||
//
|
||||
// The snapshot sits in the keychain group the app already shares with its
|
||||
// extensions (src/lib/ios-push-shared.ts): an app group would need a new
|
||||
// capability on every provisioning profile, the keychain group needs none.
|
||||
// After each write the app asks WidgetKit to redraw (BulwarkWidgetKit).
|
||||
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { sharedStoreOptions } from '../lib/ios-push-shared';
|
||||
import { links } from './clicks';
|
||||
import { makeFmt } from './format';
|
||||
import { normalizeHex } from './theme';
|
||||
import type { WidgetSnapshot } from './snapshot';
|
||||
|
||||
/** Read by plugins/ios-widget/BulwarkWidget.swift. */
|
||||
export const IOS_WIDGET_KEY = 'bulwark_widget_inbox';
|
||||
|
||||
/** Messages a large widget can show; smaller ones take the first few. */
|
||||
const MAX_MESSAGES = 6;
|
||||
|
||||
export interface IosWidgetMessage {
|
||||
id: string;
|
||||
from: string;
|
||||
initials: string;
|
||||
color: string;
|
||||
subject: string;
|
||||
preview: string;
|
||||
receivedAt: number;
|
||||
unread: boolean;
|
||||
/** Opens the message in the app. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface IosWidgetSnapshot {
|
||||
version: 1;
|
||||
generatedAt: number;
|
||||
signedIn: boolean;
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
locale: string;
|
||||
hour12: boolean;
|
||||
/** The active account's label; null with a single account. */
|
||||
account: string | null;
|
||||
unread: number;
|
||||
inboxUrl: string;
|
||||
composeUrl: string;
|
||||
messages: IosWidgetMessage[];
|
||||
strings: { inbox: string; unread: string; empty: string; signedOut: string; noSubject: string };
|
||||
}
|
||||
|
||||
export function iosWidgetSnapshot(s: WidgetSnapshot): IosWidgetSnapshot {
|
||||
const f = makeFmt(s.locale, s.hour12);
|
||||
const noSubject = f.t('widgets.mail.no_subject', '(no subject)');
|
||||
const inbox = s.mail.folders.find((folder) => folder.role === 'inbox');
|
||||
const account = s.accounts.length > 1 ? s.accounts.find((a) => a.id === s.activeAccountId)?.label ?? null : null;
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt: s.generatedAt,
|
||||
signedIn: s.signedIn,
|
||||
theme: s.theme,
|
||||
locale: s.locale,
|
||||
hour12: s.hour12,
|
||||
account,
|
||||
unread: inbox?.unread ?? 0,
|
||||
inboxUrl: links.inbox(),
|
||||
composeUrl: links.compose(),
|
||||
messages: s.mail.inbox.slice(0, MAX_MESSAGES).map((m) => ({
|
||||
id: m.id,
|
||||
from: m.fromName || m.fromEmail,
|
||||
initials: m.initials,
|
||||
color: normalizeHex(m.color),
|
||||
subject: m.subject || noSubject,
|
||||
preview: m.preview,
|
||||
receivedAt: m.receivedAt,
|
||||
unread: m.unread,
|
||||
url: links.message(m),
|
||||
})),
|
||||
strings: {
|
||||
inbox: f.t('widgets.mail.inbox', 'Inbox'),
|
||||
unread: f.t('widgets.mail.unread', 'unread'),
|
||||
empty: f.t('widgets.mail.empty_inbox', 'Your inbox is empty'),
|
||||
signedOut: f.t('widgets.state.signed_out', 'Sign in to Bulwark'),
|
||||
noSubject,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type WidgetKitModule = { reloadAll?: () => Promise<void>; placedCount?: () => Promise<number> };
|
||||
|
||||
function widgetKit(): WidgetKitModule | undefined {
|
||||
return NativeModules.BulwarkWidgetKit as WidgetKitModule | undefined;
|
||||
}
|
||||
|
||||
/** Whether an inbox widget is on the home screen (or the module can't tell). */
|
||||
export async function iosWidgetPlaced(): Promise<boolean> {
|
||||
const count = widgetKit()?.placedCount;
|
||||
if (!count) return false;
|
||||
try {
|
||||
return (await count()) > 0;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand the widget what it shows and have WidgetKit redraw it. */
|
||||
export async function writeIosWidget(s: WidgetSnapshot): Promise<void> {
|
||||
if (Platform.OS !== 'ios') return;
|
||||
const options = sharedStoreOptions();
|
||||
if (!options) return;
|
||||
await SecureStore.setItemAsync(IOS_WIDGET_KEY, JSON.stringify(iosWidgetSnapshot(s)), options);
|
||||
await widgetKit()?.reloadAll?.().catch(() => undefined);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { serial } from './serial';
|
||||
import { readView } from './state';
|
||||
import type { WidgetSnapshot } from './snapshot';
|
||||
import { DARK, LIGHT, type WidgetPalette } from './theme';
|
||||
import { iosWidgetPlaced, writeIosWidget } from './ios';
|
||||
import type { Layout, WidgetLocalState } from './layouts/types';
|
||||
import * as mail from './layouts/mail';
|
||||
import * as moreMail from './layouts/more-mail';
|
||||
@@ -161,11 +162,13 @@ let queued: Promise<void> | null = null;
|
||||
* one drawn is the newest; calls made while one is still waiting share it.
|
||||
*/
|
||||
export function redrawAll(): Promise<void> {
|
||||
if (Platform.OS !== 'android') return Promise.resolve();
|
||||
if (Platform.OS !== 'android' && Platform.OS !== 'ios') return Promise.resolve();
|
||||
if (queued) return queued;
|
||||
const run = serial(async () => {
|
||||
queued = null;
|
||||
await updateAllWidgets(await readView());
|
||||
// iOS draws its WidgetKit widget itself from a snapshot handed over (./ios.ts).
|
||||
if (Platform.OS === 'ios') await writeIosWidget(await readView());
|
||||
else await updateAllWidgets(await readView());
|
||||
});
|
||||
queued = run;
|
||||
return run;
|
||||
@@ -180,10 +183,11 @@ let placedCache: { at: number; value: boolean } | null = null;
|
||||
|
||||
/** Whether any Bulwark widget is on a home screen; cached briefly. */
|
||||
export async function hasPlacedWidgets(maxAgeMs = 60_000): Promise<boolean> {
|
||||
if (Platform.OS !== 'android') return false;
|
||||
if (Platform.OS !== 'android' && Platform.OS !== 'ios') return false;
|
||||
if (placedCache && Date.now() - placedCache.at < maxAgeMs) return placedCache.value;
|
||||
const counts = await Promise.all(WIDGET_NAMES.map((n) => getWidgetInfo(n).then((l) => l.length).catch(() => 0)));
|
||||
const value = counts.some((c) => c > 0);
|
||||
const value = Platform.OS === 'ios'
|
||||
? await iosWidgetPlaced()
|
||||
: (await Promise.all(WIDGET_NAMES.map((n) => getWidgetInfo(n).then((l) => l.length).catch(() => 0)))).some((c) => c > 0);
|
||||
placedCache = { at: Date.now(), value };
|
||||
return value;
|
||||
}
|
||||
|
||||
+9
-3
@@ -48,6 +48,12 @@ export function handleAppState(next: string): void {
|
||||
if (next !== 'background') return;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
// iOS has no headless task to fetch in: hand the WidgetKit widget what the
|
||||
// app already has, which needs no network.
|
||||
if (Platform.OS === 'ios') {
|
||||
void redrawAll().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const native = NativeModules.BulwarkWidgets as { refreshInBackground?: () => Promise<boolean> } | undefined;
|
||||
void (native?.refreshInBackground?.() ?? Promise.resolve(false))
|
||||
.catch(() => false)
|
||||
@@ -56,9 +62,9 @@ export function handleAppState(next: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Start following app state; returns the cleanup. No-op off Android. */
|
||||
/** Start following app state; returns the cleanup. No-op off Android and iOS. */
|
||||
export function startWidgetSync(): () => void {
|
||||
if (Platform.OS !== 'android') return () => {};
|
||||
if (Platform.OS !== 'android' && Platform.OS !== 'ios') return () => {};
|
||||
const { useEmailStore } = require('../stores/email-store') as typeof import('../stores/email-store');
|
||||
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
|
||||
const { useSettingsStore } = require('../stores/settings-store') as typeof import('../stores/settings-store');
|
||||
@@ -93,7 +99,7 @@ export function startWidgetSync(): () => void {
|
||||
|
||||
/** Wipe what the widgets show after the last account signed out. */
|
||||
export async function signOutWidgets(): Promise<void> {
|
||||
if (Platform.OS !== 'android') return;
|
||||
if (Platform.OS !== 'android' && Platform.OS !== 'ios') return;
|
||||
// Signed out of every account, not merely offline or mid-switch.
|
||||
if ((await readRegistry()).accounts.length > 0) return;
|
||||
await replaceAll({ ...emptySnapshot(), generatedAt: Date.now() });
|
||||
|
||||
Reference in New Issue
Block a user