Merge branch 'main' into feat/stalwart-1.0

This commit is contained in:
Linus Rath
2026-10-29 01:30:03 +01:00
782 changed files with 202548 additions and 95749 deletions
+181
View File
@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { MailItem, WidgetSnapshot } from '../snapshot';
// A fake server and a fake refresh: the refresh reads the server when it
// starts and stores what it read when `release` lets it finish, which is how
// a slow refresh overlaps a second tap on the device.
const server = vi.hoisted(() => ({ inbox: [] as string[] }));
const calls = vi.hoisted(() => ({ moves: [] as Array<{ id: string; resolve: (ok: boolean) => void }> }));
vi.mock('../jmap', () => ({
openClient: vi.fn(async () => ({})),
markRead: vi.fn(async () => true),
rsvp: vi.fn(async () => true),
setTaskDone: vi.fn(async () => true),
moveTo: vi.fn((_client: unknown, id: string) => new Promise<boolean>((resolve) => {
calls.moves.push({
id,
resolve: (ok) => {
if (ok) server.inbox = server.inbox.filter((x) => x !== id);
resolve(ok);
},
});
})),
}));
vi.mock('../render', () => ({ redrawAll: vi.fn(async () => undefined) }));
vi.mock('../build', async () => {
const { storeRefresh } = await import('../state');
return {
singletonServes: () => false,
refreshSnapshot: vi.fn(async () => {
const startedAt = Date.now();
const read = [...server.inbox];
await new Promise((r) => setTimeout(r, 5));
await storeRefresh(snapshotOf(read), startedAt);
}),
};
});
function mail(id: string): MailItem {
return {
id, threadId: `t${id}`, accountId: 'acc', fromName: id, fromEmail: `${id}@x`, initials: 'X', color: '#123456',
subject: id, preview: '', receivedAt: 0, unread: true, starred: false, hasAttachment: false, threadSize: 1,
};
}
function snapshotOf(ids: string[]): WidgetSnapshot {
return {
version: 1, generatedAt: Date.now(), appDataAt: 0, signedIn: true, theme: 'light', locale: 'en', hour12: false,
weekStart: 1, accounts: [], activeAccountId: 'acc',
mail: {
folders: [{ role: 'inbox', name: 'Inbox', unread: ids.length, total: ids.length }],
inbox: ids.map(mail), unified: [], starred: [], starredCount: 0, drafts: [], draftCount: 0, scheduled: [],
pendingChanges: 0, favourites: [], recentSearches: [], tags: [], attachments: [],
},
calendar: { supported: true, events: [], invitations: [], birthdays: [] },
tasks: { supported: true, items: [] },
files: { supported: false, items: [] },
vacation: null,
quota: null,
};
}
const jmap = await import('../jmap');
const { handleWidgetAction } = await import('../actions');
const { currentView } = await import('../state');
const { saveSnapshot } = await import('../snapshot');
const shown = async () => (await currentView()).mail.inbox.map((m) => m.id);
const until = async (check: () => boolean) => {
for (let i = 0; i < 200 && !check(); i++) await new Promise((r) => setTimeout(r, 2));
expect(check()).toBe(true);
};
beforeEach(async () => {
await AsyncStorage.clear();
calls.moves = [];
server.inbox = ['m1', 'm2', 'm3'];
await saveSnapshot(snapshotOf(server.inbox));
});
describe('widget task ticks and RSVPs', () => {
it('go to the account the tapped drawing showed, not the one the widgets show now', async () => {
// The widgets moved on to acc2, which has a task with the same JMAP id.
const s = snapshotOf([]);
s.activeAccountId = 'acc2';
s.tasks.items = [{
id: 'e', serverId: 'e', accountId: 'acc2', title: 'Theirs', dueHasTime: false, done: false, calendarName: '', color: '#123456',
}];
await saveSnapshot(s);
vi.mocked(jmap.openClient).mockClear();
vi.mocked(jmap.setTaskDone).mockClear();
await handleWidgetAction('toggleTask', { id: 'e', done: true, serverId: 'e', accountId: 'acc', title: 'Mine' }, 1);
expect(jmap.openClient).toHaveBeenCalledWith('acc');
expect(jmap.setTaskDone).toHaveBeenCalledWith(expect.anything(), 'e', true, undefined);
});
it('mark a task done on both of two quick taps rather than undoing the first', async () => {
vi.mocked(jmap.setTaskDone).mockClear();
const tap = { id: 't', done: true, serverId: 't', accountId: 'acc', title: 'Task' };
await Promise.all([handleWidgetAction('toggleTask', tap, 1), handleWidgetAction('toggleTask', tap, 1)]);
expect(vi.mocked(jmap.setTaskDone).mock.calls.map((c) => c[2])).toEqual([true, true]);
});
it('send an RSVP with the ids the tap carries', async () => {
vi.mocked(jmap.rsvp).mockClear();
await handleWidgetAction('rsvp', {
id: 'h1', status: 'accepted', serverId: 'j', participantId: 'a', accountId: 'acc', jmapAccountId: 'd', title: 'Budget',
}, 1);
expect(jmap.rsvp).toHaveBeenCalledWith(expect.anything(), 'j', 'a', 'accepted', 'd');
});
});
describe('widget archive', () => {
it('keeps a message archived when a refresh read the server before its archive landed', async () => {
const first = handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7);
const second = handleWidgetAction('archive', { id: 'm2', accountId: 'acc' }, 7);
await until(() => calls.moves.length === 2);
expect(await shown()).toEqual(['m3']);
// The first archive lands; its refresh reads a server that still has m2.
calls.moves[0].resolve(true);
await first;
expect(server.inbox).toEqual(['m2', 'm3']);
expect(await shown()).toEqual(['m3']);
calls.moves[1].resolve(true);
await second;
expect(await shown()).toEqual(['m3']);
});
it('brings a message back when its archive did not reach the server', async () => {
const tap = handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7);
await until(() => calls.moves.length === 1);
expect(await shown()).toEqual(['m2', 'm3']);
calls.moves[0].resolve(false);
await tap;
expect(await shown()).toEqual(['m1', 'm2', 'm3']);
});
it('says so on the widget, with the tap to repeat, and shows the message again', async () => {
const tap = handleWidgetAction('archive', { id: 'm2', accountId: 'acc' }, 7);
await until(() => calls.moves.length === 1);
calls.moves[0].resolve(false);
await tap;
const view = await currentView();
expect(view.notice).toMatchObject({ action: 'archive', label: 'm2', retry: { id: 'm2', accountId: 'acc' } });
expect(JSON.parse((await AsyncStorage.getItem('widgets:local:7'))!)).toEqual({ triageId: 'm2' });
// Tapping the notice repeats the archive and clears it.
const retry = handleWidgetAction(view.notice!.action, view.notice!.retry, 7);
await until(() => calls.moves.length === 2);
expect((await currentView()).notice).toBeNull();
calls.moves[1].resolve(true);
await retry;
expect(await shown()).toEqual(['m1', 'm3']);
expect((await currentView()).notice).toBeNull();
});
it('does not lose either of two taps that land together', async () => {
const taps = [
handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7),
handleWidgetAction('trash', { id: 'm3', accountId: 'acc' }, 7),
];
await until(() => calls.moves.length === 2);
expect(await shown()).toEqual(['m2']);
for (const move of calls.moves) move.resolve(true);
await Promise.all(taps);
expect(await shown()).toEqual(['m2']);
});
it('moves the triage card on to the next unread message', async () => {
const tap = handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7);
await until(() => calls.moves.length === 1);
expect(JSON.parse((await AsyncStorage.getItem('widgets:local:7'))!)).toEqual({ triageId: 'm2' });
calls.moves[0].resolve(true);
await tap;
});
});
+146
View File
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { ContactCard } from '../../api/types';
import { avatarImageFor, imageType, resolveAvatarImages, toBase64 } from '../avatar-images';
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
const ICO = new Uint8Array([0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x10, 0x10, 0, 0]);
const PHOTO = 'data:image/jpeg;base64,/9j/AAAA';
function card(email: string, uri: string): ContactCard {
return {
id: email,
emails: { e1: { address: email } },
media: { m1: { kind: 'photo', uri } },
} as unknown as ContactCard;
}
function answer(bytes: Uint8Array | null, ok = true) {
return {
ok,
arrayBuffer: async () => (bytes ?? new Uint8Array()).buffer,
};
}
const fetchMock = vi.fn();
beforeEach(async () => {
await AsyncStorage.clear();
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('imageType', () => {
it('names the types BitmapFactory draws', () => {
expect(imageType(PNG)).toBe('image/png');
expect(imageType(ICO)).toBe('image/x-icon');
expect(imageType(new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]))).toBe('image/jpeg');
expect(imageType(new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0]))).toBe('image/gif');
expect(imageType(new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]))).toBe('image/webp');
});
it('refuses SVG, HTML and short answers', () => {
expect(imageType(new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"/>'))).toBeNull();
expect(imageType(new TextEncoder().encode('<!doctype html><html>'))).toBeNull();
expect(imageType(new Uint8Array([0x89, 0x50]))).toBeNull();
});
});
describe('toBase64', () => {
it('matches the standard encoding with padding', () => {
for (const text of ['', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar']) {
expect(toBase64(new TextEncoder().encode(text))).toBe(Buffer.from(text).toString('base64'));
}
const all = new Uint8Array(256).map((_, i) => i);
expect(toBase64(all)).toBe(Buffer.from(all).toString('base64'));
});
});
describe('resolveAvatarImages', () => {
it('prefers a contact photo and does not fetch a logo for that sender', async () => {
const images = await resolveAvatarImages(['Ann@Example.com'], {
contacts: [card('ann@example.com', PHOTO)],
favicons: true,
});
expect(images.photos['ann@example.com']).toBe(PHOTO);
expect(fetchMock).not.toHaveBeenCalled();
expect(avatarImageFor(images, 'ann@example.com')).toEqual({ src: PHOTO, logo: false });
});
it('leaves out hosted contact photos (they need the account to load)', async () => {
fetchMock.mockResolvedValue(answer(null, false));
const images = await resolveAvatarImages(['ann@example.com'], {
contacts: [card('ann@example.com', 'https://mail.example.com/jmap/download/x')],
favicons: true,
});
expect(images.photos).toEqual({});
});
it('fetches a logo per business domain, not for personal providers', async () => {
fetchMock.mockResolvedValue(answer(PNG));
const images = await resolveAvatarImages(['news@shop.acme.io', 'bob@gmail.com', 'eve@acme.io'], {
contacts: [],
favicons: true,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toContain('acme.io');
const logo = avatarImageFor(images, 'eve@acme.io');
expect(logo).toEqual({ src: `data:image/png;base64,${toBase64(PNG)}`, logo: true });
expect(avatarImageFor(images, 'bob@gmail.com')).toBeNull();
});
it('fetches no logos when sender favicons are off', async () => {
const images = await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: false });
expect(fetchMock).not.toHaveBeenCalled();
expect(images.favicons).toEqual({});
});
it('keeps logos that are not images or too big out', async () => {
fetchMock
.mockResolvedValueOnce(answer(new TextEncoder().encode('<!doctype html><html></html>')))
.mockResolvedValueOnce(answer(new Uint8Array(50 * 1024).fill(0).map((v, i) => (i < 8 ? PNG[i] : v))));
const images = await resolveAvatarImages(['a@one.dev', 'b@two.dev'], { contacts: [], favicons: true });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(images.favicons).toEqual({});
});
it('asks a domain again only after its cache time', async () => {
fetchMock.mockResolvedValue(answer(ICO));
const now = Date.UTC(2026, 9, 1);
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now });
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 6 * 86400000 });
expect(fetchMock).toHaveBeenCalledTimes(1);
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 8 * 86400000 });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('keeps the cached logo when offline', async () => {
const now = Date.UTC(2026, 9, 1);
fetchMock.mockResolvedValueOnce(answer(PNG));
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now });
fetchMock.mockRejectedValueOnce(new TypeError('Network request failed'));
const later = await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 30 * 86400000 });
expect(avatarImageFor(later, 'eve@acme.io')?.logo).toBe(true);
});
it('remembers a domain without a logo for a day', async () => {
const now = Date.UTC(2026, 9, 1);
fetchMock.mockResolvedValue(answer(null, false));
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now });
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 3600000 });
expect(fetchMock).toHaveBeenCalledTimes(1);
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 2 * 86400000 });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('avatarImageFor', () => {
it('has nothing without pictures or an address', () => {
expect(avatarImageFor(undefined, 'eve@acme.io')).toBeNull();
expect(avatarImageFor({ photos: {}, favicons: {} }, undefined)).toBeNull();
});
});
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest';
import {
agendaDays,
busyBlocks,
countdownTarget,
dueKind,
eventsOnDay,
freeTime,
monthGrid,
nextEvent,
sortTasks,
startOfWeek,
} from '../derive';
import type { EventItem, TaskItem } from '../snapshot';
// Monday 28 September 2026, 09:05 local time.
const NOW = new Date(2026, 8, 28, 9, 5).getTime();
const at = (day: number, h: number, m = 0) => new Date(2026, 8, day, h, m).getTime();
function ev(id: string, start: number, end: number, extra: Partial<EventItem> = {}): EventItem {
return {
id,
serverId: id,
title: id,
start,
end,
allDay: false,
color: '#22c55e',
calendarName: 'Work',
participants: [],
...extra,
};
}
const standup = ev('standup', at(28, 9, 30), at(28, 10));
const dentist = ev('dentist', at(29, 10), at(29, 11), { color: '#3b82f6' });
const lunch = ev('lunch', at(30, 12, 30), at(30, 13, 30));
const party = ev('party', new Date(2026, 9, 1).getTime(), new Date(2026, 9, 2).getTime(), { allDay: true, color: '#3b82f6' });
const review = ev('review', new Date(2026, 9, 1, 14).getTime(), new Date(2026, 9, 1, 16).getTime());
const trip = ev('trip', new Date(2026, 9, 4).getTime(), new Date(2026, 9, 5).getTime(), { allDay: true, color: '#3b82f6' });
const earlier = ev('earlier', at(28, 7), at(28, 8));
const EVENTS = [earlier, standup, dentist, lunch, party, review, trip];
describe('calendar views', () => {
it('finds the next timed event and whether it is running', () => {
expect(nextEvent(EVENTS, NOW)?.event.id).toBe('standup');
expect(nextEvent(EVENTS, NOW)?.running).toBe(false);
expect(nextEvent(EVENTS, at(28, 9, 45))?.running).toBe(true);
});
it('lists a day with all-day events first', () => {
const thursday = new Date(2026, 9, 1).getTime();
expect(eventsOnDay(EVENTS, thursday).map((e) => e.id)).toEqual(['party', 'review']);
});
it('drops finished events from today in the agenda', () => {
const days = agendaDays(EVENTS, NOW, 3);
expect(days.map((d) => d.events.map((e) => e.id))).toEqual([['standup'], ['dentist'], ['lunch']]);
});
it('starts the week on the configured day', () => {
expect(new Date(startOfWeek(NOW, 1)).getDate()).toBe(28);
expect(new Date(startOfWeek(NOW, 0)).getDate()).toBe(27);
});
it('builds the month as whole weeks with dots and today', () => {
const weeks = monthGrid(EVENTS, NOW, 1);
expect(weeks).toHaveLength(5);
expect(weeks[0][0].day).toBe(31);
expect(weeks[0][0].inMonth).toBe(false);
const today = weeks.flat().find((c) => c.isToday);
expect(today?.day).toBe(28);
expect(today?.dots).toEqual(['#22c55e']);
});
it('places busy blocks inside working hours', () => {
const [block] = busyBlocks(EVENTS, new Date(2026, 8, 29).getTime());
expect(block.from).toBeCloseTo(0.2);
expect(block.to).toBeCloseTo(0.3);
});
it('works out free time and the longest gap left today', () => {
const free = freeTime(EVENTS, NOW);
expect(free.busyNow).toBe(false);
expect(free.next?.id).toBe('standup');
expect(free.longestGap).toEqual({ from: at(28, 10), to: at(28, 18) });
});
it('counts down to the next all-day or multi-day event', () => {
expect(countdownTarget(EVENTS, NOW)?.id).toBe('party');
});
});
describe('tasks', () => {
const task = (id: string, due: number | undefined, done = false, dueHasTime = false): TaskItem => ({
id, serverId: id, title: id, due, dueHasTime, done, calendarName: 'Personal', color: '#3b82f6',
});
it('classifies due dates', () => {
expect(dueKind(task('a', at(26, 0)), NOW)).toBe('overdue');
expect(dueKind(task('b', at(28, 0)), NOW)).toBe('today');
expect(dueKind(task('c', at(28, 8), false, true), NOW)).toBe('overdue');
expect(dueKind(task('d', at(29, 0)), NOW)).toBe('tomorrow');
expect(dueKind(task('e', undefined), NOW)).toBe('none');
});
it('puts overdue first and done last', () => {
const sorted = sortTasks([
task('done', at(20, 0), true),
task('later', new Date(2026, 9, 3).getTime()),
task('none', undefined),
task('today', at(28, 0)),
task('overdue', at(26, 0)),
], NOW);
expect(sorted.map((t) => t.id)).toEqual(['overdue', 'today', 'later', 'none', 'done']);
});
});
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { makeFmt } from '../format';
describe('count', () => {
it('keeps counts as they are below 10,000 and shortens bigger ones', () => {
const f = makeFmt('en', false);
expect(f.count(0)).toBe('0');
expect(f.count(9999)).toBe('9999');
expect(f.count(10007)).toBe('10K');
expect(f.count(12345)).toBe('12.3K');
expect(f.count(234567)).toBe('235K');
expect(f.count(2_500_000)).toBe('2.5M');
expect(makeFmt('de', false).count(12345)).toBe('12,3K');
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import type { Email, Mailbox } from '../../api/types';
import { folderCounts, rankPeople, toMailItem } from '../jmap';
function email(id: string, from: string, name: string, seen: boolean, receivedAt = '2026-09-28T08:00:00Z'): Email {
return {
id,
threadId: `t-${id}`,
mailboxIds: { inbox: true },
keywords: seen ? { $seen: true } : {},
receivedAt,
from: [{ email: from, name }],
subject: `Subject ${id}`,
preview: ' line one\n line two ',
hasAttachment: false,
size: 1,
} as Email;
}
describe('toMailItem', () => {
it('maps a list email to what the widgets draw', () => {
const item = toMailItem(email('1', 'sofia.russo@example.com', 'Sofia Russo', false), 'acc', { threadSize: 2 });
expect(item).toMatchObject({
id: '1',
accountId: 'acc',
fromName: 'Sofia Russo',
initials: 'SR',
unread: true,
threadSize: 2,
preview: 'line one line two',
});
// Same hue as the webmail's Avatar for this name.
expect(item.color).toBe('#6826d9');
});
});
describe('rankPeople', () => {
it('ranks senders by volume, skips the user and no-reply senders, and counts unread', () => {
const people = rankPeople([
email('1', 'ada@example.com', 'Ada', false),
email('2', 'ada@example.com', 'Ada', true),
email('3', 'bob@example.com', 'Bob', false),
email('4', 'me@example.com', 'Me', false),
email('5', 'noreply@shop.example', 'Shop', false),
], ['Me@example.com']);
expect(people.map((p) => [p.email, p.unread])).toEqual([['ada@example.com', 1], ['bob@example.com', 1]]);
});
});
describe('folderCounts', () => {
it('orders the roles like the sidebar and finds junk by role or name', () => {
const boxes = [
{ id: 'j', name: 'Spam', role: 'junk', unreadEmails: 3, totalEmails: 3 },
{ id: 'i', name: 'Inbox', role: 'inbox', unreadEmails: 10, totalEmails: 23 },
{ id: 'd', name: 'Drafts', role: 'drafts', unreadEmails: 0, totalEmails: 2 },
] as Mailbox[];
expect(folderCounts(boxes).map((f) => [f.role, f.unread, f.total])).toEqual([
['inbox', 10, 23],
['drafts', 0, 2],
['junk', 3, 3],
]);
});
});
+166
View File
@@ -0,0 +1,166 @@
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 };
};
// The compiled ImageWidget requires react-native too; the widgets only draw data URIs.
function ImageWidget() {
return null;
}
ImageWidget.__name__ = 'ImageWidget';
ImageWidget.convertProps = (props: { style?: object; image: unknown; imageWidth: number; imageHeight: number; radius?: number }) => {
if (typeof props.image !== 'string' || !props.image.startsWith('data:image')) throw new Error('widgets draw data-URI images only');
return {
...convertCommonStyle(props.style ?? {}),
...convertClickAction(props),
imageWidth: props.imageWidth,
imageHeight: props.imageHeight,
image: { uri: props.image },
...(props.radius ? { radius: props.radius } : {}),
};
};
return {
...checked(await import('react-native-android-widget/lib/commonjs/widgets/FlexWidget' as string)),
...checked({ ImageWidget }),
...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 every action widget with a failure notice at every size', () => {
const notices = [
['TriageWidget', 'archive'],
['TriageWidget', 'trash'],
['InvitationsWidget', 'rsvp'],
['TasksWidget', 'toggleTask'],
['TodayHubWidget', 'toggleTask'],
] as const;
for (const [name, action] of notices) {
const w = catalog.widgets.find((x) => x.name === name)!;
const s = { ...sample, notice: { action, label: 'A task with a rather long title', at: PREVIEW_NOW, retry: { id: 'x' } } };
for (const [cols, rows] of sizes(w)) build(name, s, cols, rows);
}
});
it('builds avatars with contact photos and sender logos', () => {
const png = 'data:image/png;base64,iVBORw0KGgo=';
const images = {
photos: Object.fromEntries(sample.mail.inbox.slice(0, 2).map((m) => [m.fromEmail.toLowerCase(), png])),
favicons: Object.fromEntries(sample.mail.inbox.map((m) => [m.fromEmail.split('@')[1], png])),
};
for (const w of catalog.widgets) build(w.name, { ...sample, images }, 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' });
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import {
applyPending,
confirmedBy,
DONE_TTL_MS,
expire,
IN_FLIGHT_TTL_MS,
NOTICE_TTL_MS,
type PendingOp,
} from '../pending';
import { PREVIEW_NOW, sampleSnapshot } from '../sample-snapshot';
const op = (change: PendingOp['change'], extra: Partial<PendingOp> = {}): PendingOp => ({
key: `${change.kind}:${change.id}`,
change,
at: 1000,
...extra,
});
describe('applyPending', () => {
it('takes an archived message out of every list and the inbox counts', () => {
const s = sampleSnapshot(PREVIEW_NOW);
const target = s.mail.inbox.find((m) => m.unread)!;
const inbox = s.mail.folders.find((f) => f.role === 'inbox')!;
const shown = applyPending(s, [op({ kind: 'removeMail', id: target.id })]);
expect(shown.mail.inbox.some((m) => m.id === target.id)).toBe(false);
expect(shown.mail.unified.some((m) => m.id === target.id)).toBe(false);
const after = shown.mail.folders.find((f) => f.role === 'inbox')!;
expect([after.total, after.unread]).toEqual([inbox.total - 1, inbox.unread - 1]);
// The stored snapshot is left as the server described it.
expect(s.mail.inbox.some((m) => m.id === target.id)).toBe(true);
expect(s.mail.folders.find((f) => f.role === 'inbox')!.total).toBe(inbox.total);
});
it('marks a message read once', () => {
const s = sampleSnapshot(PREVIEW_NOW);
const target = s.mail.inbox.find((m) => m.unread)!;
const before = s.mail.folders.find((f) => f.role === 'inbox')!.unread;
const twice = [op({ kind: 'markRead', id: target.id }), op({ kind: 'markRead', id: target.id }, { key: 'b' })];
const shown = applyPending(s, twice);
expect(shown.mail.inbox.find((m) => m.id === target.id)!.unread).toBe(false);
expect(shown.mail.folders.find((f) => f.role === 'inbox')!.unread).toBe(before - 1);
});
it('answers an invitation and ticks a task', () => {
const s = sampleSnapshot(PREVIEW_NOW);
const inv = s.calendar.invitations[0];
const task = s.tasks.items.find((t) => !t.done)!;
const shown = applyPending(s, [
op({ kind: 'rsvp', id: inv.id, serverId: inv.serverId, status: 'accepted' }),
op({ kind: 'task', id: task.id, done: true }),
]);
expect(shown.calendar.invitations.some((i) => i.serverId === inv.serverId)).toBe(false);
expect(shown.tasks.items.find((t) => t.id === task.id)!.done).toBe(true);
expect(s.tasks.items.find((t) => t.id === task.id)!.done).toBe(false);
});
});
describe('applyPending across accounts', () => {
it('leaves an item of another account with the same id alone', () => {
const s = sampleSnapshot(PREVIEW_NOW);
const task = s.tasks.items.find((t) => !t.done)!;
const shown = applyPending(s, [op({ kind: 'task', id: task.id, done: true, accountId: 'not-this-one' })]);
expect(shown.tasks.items.find((t) => t.id === task.id)!.done).toBe(false);
const own = applyPending(s, [op({ kind: 'task', id: task.id, done: true, accountId: s.activeAccountId! })]);
expect(own.tasks.items.find((t) => t.id === task.id)!.done).toBe(true);
});
});
describe('confirmedBy', () => {
const mail = op({ kind: 'removeMail', id: 'm' }, { doneAt: 2000 });
const task = op({ kind: 'task', id: 't', done: true }, { doneAt: 2000 });
const inFlight = op({ kind: 'removeMail', id: 'n' });
it('keeps a change a refresh started before it reached the server', () => {
// The refresh read the server at 1500, before the archive landed at 2000:
// its data still has the message, so the op must keep hiding it.
expect(confirmedBy([mail, inFlight], 1500, true)).toEqual([mail, inFlight]);
});
it('drops a mail change once a later refresh has stored it', () => {
expect(confirmedBy([mail, inFlight], 2500, false)).toEqual([inFlight]);
});
it('keeps a task change until a refresh that loaded tasks', () => {
expect(confirmedBy([task], 2500, false)).toEqual([task]);
expect(confirmedBy([task], 2500, true)).toEqual([]);
});
});
describe('expire', () => {
it('drops stuck and stale ops and an old notice', () => {
const now = 10_000_000;
const stuck = op({ kind: 'removeMail', id: 'a' }, { at: now - IN_FLIGHT_TTL_MS - 1 });
const fresh = op({ kind: 'removeMail', id: 'b' }, { at: now - 1000 });
const stale = op({ kind: 'task', id: 'c', done: true }, { at: 0, doneAt: now - DONE_TTL_MS - 1 });
const recent = op({ kind: 'task', id: 'd', done: true }, { at: 0, doneAt: now - 1000 });
const notice = (at: number) => ({ action: 'archive' as const, label: 'x', at, retry: {} });
expect(expire({ ops: [stuck, fresh, stale, recent], notice: notice(now - NOTICE_TTL_MS - 1) }, now))
.toEqual({ ops: [fresh, recent], notice: null });
expect(expire({ ops: [], notice: notice(now - 1000) }, now).notice).toEqual(notice(now - 1000));
});
});
+52
View File
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const native = vi.hoisted(() => ({ BulwarkWidgets: undefined as undefined | { refreshInBackground: () => Promise<boolean> } }));
vi.mock('react-native', () => ({ Platform: { OS: 'android' }, AppState: {}, NativeModules: native }));
const refreshSnapshot = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock('../build', () => ({ refreshSnapshot, readRegistry: vi.fn() }));
vi.mock('../render', () => ({ hasPlacedWidgets: vi.fn(async () => true), redrawAll: vi.fn(async () => undefined) }));
vi.mock('../state', () => ({ replaceAll: vi.fn() }));
const { handleAppState } = await import('../sync');
afterEach(() => {
vi.useRealTimers();
refreshSnapshot.mockClear();
});
describe('widget sync', () => {
it('refreshes when the app goes to the background without waiting for a timer', async () => {
// React Native pauses JS timers while the activity is in the background,
// so not even a zero-delay timer may stand between leaving the app and
// the refresh. Fake timers that never advance stand in for that.
vi.useFakeTimers();
handleAppState('background');
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(refreshSnapshot).toHaveBeenCalledTimes(1);
});
it('hands the refresh to a headless task where the app has one', async () => {
vi.useFakeTimers();
const refreshInBackground = vi.fn(async () => true);
native.BulwarkWidgets = { refreshInBackground };
handleAppState('background');
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(refreshInBackground).toHaveBeenCalledTimes(1);
expect(refreshSnapshot).not.toHaveBeenCalled();
// Android refused to start the service: refresh in place after all.
refreshInBackground.mockResolvedValueOnce(false);
handleAppState('background');
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(refreshSnapshot).toHaveBeenCalledTimes(1);
native.BulwarkWidgets = undefined;
});
it('leaves the app coming back to the scheduled refreshes', async () => {
vi.useFakeTimers();
handleAppState('active');
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(refreshSnapshot).not.toHaveBeenCalled();
});
});
+174
View File
@@ -0,0 +1,174 @@
// Buttons that act without opening the app: archive / delete / next on the
// triage card, RSVP on the invitation card, ticking a task. Each one lays its
// change over the stored data at once (./state.ts) so every widget redraws
// without waiting, then makes the JMAP call. A change the server took stays
// laid over the data until a refresh has caught up with it; one that failed
// is dropped again, which shows the data as the server last described it,
// and the widget says so with a notice that repeats the tap.
import { jmapClient } from '../api/jmap-client';
import type { JMAPClient } from '../api/jmap-client';
import { refreshSnapshot, singletonServes } from './build';
import { markRead, moveTo, openClient, rsvp, setTaskDone } from './jmap';
import { loadLocal, saveLocal } from './local-state';
import type { PendingChange } from './pending';
import type { ActionNotice } from './snapshot';
import { redrawAll } from './render';
import { serial } from './serial';
import { currentView, settle, track } from './state';
async function clientFor(registryAccountId: string | null | undefined): Promise<JMAPClient | null> {
if (!registryAccountId) return null;
if (singletonServes(registryAccountId)) return jmapClient;
return openClient(registryAccountId);
}
type Data = Record<string, unknown>;
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
/**
* Lay `change` over the data, run `call`, and keep or drop the change by its
* answer. On failure the widget shows a notice about `label` that repeats
* the tap (`name` with `data`).
*/
async function apply(
name: ActionNotice['action'],
data: Data,
label: string,
change: PendingChange,
call: () => Promise<boolean>,
onFailed?: () => Promise<void>,
): Promise<boolean> {
const key = await track(change);
await redrawAll();
let ok = false;
try {
ok = await call();
} catch (err) {
console.warn(`[widgets] ${name} failed`, err);
}
if (!ok) console.warn(`[widgets] ${name} was not applied on the server`);
await settle(key, ok, { action: name, label, at: Date.now(), retry: data });
if (!ok) await onFailed?.();
await redrawAll();
// Catch up with the server: confirms a change that went through and picks
// up whatever else changed. Offline this fails quickly and keeps the data.
await refreshSnapshot({ after: 'change' });
await redrawAll();
return ok;
}
/** The triage card's message after `id`, among what the widget shows now. */
async function moveTriagePointer(widgetId: number, id: string): Promise<void> {
const view = await currentView();
const unread = view.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;
await pointTriage(widgetId, next?.id ?? null);
}
function pointTriage(widgetId: number, triageId: string | null): Promise<void> {
return serial(async () => saveLocal(widgetId, { ...(await loadLocal(widgetId)), triageId }));
}
export async function handleWidgetAction(name: string, data: Data, widgetId: number): Promise<void> {
switch (name) {
case 'refresh':
await refreshSnapshot({ after: 'change' });
await redrawAll();
return;
case 'triageNext': {
const view = await currentView();
const unread = view.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];
await pointTriage(widgetId, next.id);
await redrawAll();
return;
}
case 'archive':
case 'trash':
case 'markRead': {
const id = str(data.id);
if (!id) return;
const subject = (await currentView()).mail.inbox.find((m) => m.id === id)?.subject ?? '';
// The triage card moves on to the next unread message, not back to
// the first one.
if (name !== 'markRead') await moveTriagePointer(widgetId, id);
const accountId = str(data.accountId);
const jmapAccountId = str(data.jmapAccountId);
const change: PendingChange = name === 'markRead' ? { kind: 'markRead', id } : { kind: 'removeMail', id };
await apply(name, data, subject, change, async () => {
const client = await clientFor(accountId);
if (!client) return false;
return name === 'markRead'
? markRead(client, id, jmapAccountId)
: moveTo(client, id, name, jmapAccountId);
}, async () => {
// The card goes back to the message, next to the notice about it.
if (name !== 'markRead') await pointTriage(widgetId, id);
});
return;
}
case 'rsvp': {
const id = str(data.id);
const status = data.status;
if (!id || (status !== 'accepted' && status !== 'tentative' && status !== 'declined')) return;
// The tap carries the invitation (clicks.ts); a drawing made before it
// did is looked up in what the widgets show now.
const view = await currentView();
const shown = view.calendar.invitations.find((i) => i.id === id);
const accountId = str(data.accountId) ?? shown?.accountId ?? view.activeAccountId;
const serverId = str(data.serverId) ?? shown?.serverId;
const participantId = str(data.participantId) ?? shown?.participantId;
const jmapAccountId = str(data.jmapAccountId) ?? (str(data.serverId) ? undefined : shown?.jmapAccountId);
if (!serverId || !participantId) {
await redrawAll();
return;
}
const title = str(data.title) ?? shown?.title ?? '';
await apply('rsvp', data, title, { kind: 'rsvp', id, serverId, status, accountId: accountId ?? undefined }, async () => {
const client = await clientFor(accountId);
return !!client && rsvp(client, serverId, participantId, status, jmapAccountId);
});
return;
}
case 'toggleTask': {
const id = str(data.id);
if (!id) return;
const view = await currentView();
const shown = view.tasks.items.find((t) => t.id === id);
const accountId = str(data.accountId) ?? shown?.accountId ?? view.activeAccountId;
const serverId = str(data.serverId) ?? shown?.serverId;
const jmapAccountId = str(data.jmapAccountId) ?? (str(data.serverId) ? undefined : shown?.jmapAccountId);
// The state the user asked for; older drawings only named the task.
const done = typeof data.done === 'boolean' ? data.done : shown ? !shown.done : undefined;
if (!serverId || done === undefined) {
await redrawAll();
return;
}
const title = str(data.title) ?? shown?.title ?? '';
await apply('toggleTask', data, title, { kind: 'task', id, done, accountId: accountId ?? undefined }, async () => {
const client = await clientFor(accountId);
if (!client || !(await setTaskDone(client, serverId, done, jmapAccountId))) return false;
// In the live app the snapshot's tasks come from the calendar store,
// which did not see this change; reload it before the refresh that
// follows reads it, or that refresh would undo the tick.
if (client === jmapClient) {
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
await useCalendarStore.getState().fetchTasks().catch(() => undefined);
}
return true;
});
return;
}
default:
console.warn(`[widgets] unknown action ${name}`);
}
}
+152
View File
@@ -0,0 +1,152 @@
// Pictures for the widgets' avatars, in the webmail Avatar's order: the
// sender's contact photo, then the logo of the sender's domain (when "sender
// favicons" is on and the domain is not a personal mail provider), else the
// initials. A widget draws from the stored snapshot and cannot fetch while it
// draws (offline it would come out blank), so the pictures are fetched when
// the snapshot is built and kept in it as small data URIs.
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { ContactCard } from '../api/types';
import { getContactPhotoForEmail, getFaviconDomain, getFaviconUrl } from '../lib/avatar-utils';
import type { AvatarImages } from './snapshot';
const CACHE_KEY = 'widgets:favicons:v1';
/** A logo is fetched again after a week, a domain without one after a day. */
const HIT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MISS_TTL_MS = 24 * 60 * 60 * 1000;
/** Pictures bigger than this stay out of the snapshot (it is read on every draw). */
const MAX_BYTES = 40 * 1024;
const MAX_DOMAINS = 24;
const FETCH_TIMEOUT_MS = 4000;
type FaviconCache = Record<string, { data: string | null; at: number }>;
async function loadCache(): Promise<FaviconCache> {
try {
const raw = await AsyncStorage.getItem(CACHE_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' ? (parsed as FaviconCache) : {};
} catch {
return {};
}
}
/** The image type of `bytes` when BitmapFactory can draw it, from its first bytes. */
export function imageType(bytes: Uint8Array): string | null {
const b = (i: number) => bytes[i];
if (bytes.length < 8) return null;
if (b(0) === 0x89 && b(1) === 0x50 && b(2) === 0x4e && b(3) === 0x47) return 'image/png';
if (b(0) === 0x00 && b(1) === 0x00 && b(2) === 0x01 && b(3) === 0x00) return 'image/x-icon';
if (b(0) === 0xff && b(1) === 0xd8 && b(2) === 0xff) return 'image/jpeg';
if (b(0) === 0x47 && b(1) === 0x49 && b(2) === 0x46) return 'image/gif';
if (b(0) === 0x52 && b(1) === 0x49 && b(2) === 0x46 && b(3) === 0x46 && b(8) === 0x57) return 'image/webp';
return null;
}
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
export function toBase64(bytes: Uint8Array): string {
let out = '';
for (let i = 0; i < bytes.length; i += 3) {
const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : '=';
out += i + 2 < bytes.length ? B64[n & 63] : '=';
}
return out;
}
/** The domain's logo as a data URI; null when there is none; undefined when it could not be asked (offline). */
async function fetchFavicon(domain: string): Promise<string | null | undefined> {
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timer = setTimeout(() => controller?.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(getFaviconUrl(domain), controller ? { signal: controller.signal } : undefined);
if (!res.ok) return null;
const bytes = new Uint8Array(await res.arrayBuffer());
const type = imageType(bytes);
if (!type || bytes.length > MAX_BYTES) return null;
return `data:${type};base64,${toBase64(bytes)}`;
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
}
/** Contact photos small enough to carry, embedded ones only (a hosted photo needs the account's credentials). */
function contactPhoto(contacts: readonly ContactCard[], email: string): string | null {
const photo = getContactPhotoForEmail(contacts, email);
const uri = photo && 'uri' in photo ? photo.uri : null;
if (!uri || !uri.startsWith('data:image') || uri.length > (MAX_BYTES * 4) / 3 + 64) return null;
return uri;
}
/**
* Pictures for `emails`: contact photos by address, logos by domain. Logos
* come from a cache of their own, fetched for at most a few domains a run.
*/
export async function resolveAvatarImages(
emails: readonly string[],
opts: { contacts: readonly ContactCard[]; favicons: boolean; now?: number },
): Promise<AvatarImages> {
const now = opts.now ?? Date.now();
const photos: Record<string, string> = {};
const domains = new Set<string>();
for (const raw of emails) {
const email = raw.trim().toLowerCase();
if (!email || photos[email]) continue;
const photo = contactPhoto(opts.contacts, email);
if (photo) {
photos[email] = photo;
continue;
}
const domain = opts.favicons ? getFaviconDomain(email) : null;
if (domain) domains.add(domain);
}
const favicons: Record<string, string> = {};
if (domains.size > 0) {
const cache = await loadCache();
let changed = false;
const wanted = [...domains].slice(0, MAX_DOMAINS);
const stale = wanted.filter((d) => {
const hit = cache[d];
return !hit || now - hit.at > (hit.data ? HIT_TTL_MS : MISS_TTL_MS);
});
for (let i = 0; i < stale.length; i += 4) {
await Promise.all(stale.slice(i, i + 4).map(async (domain) => {
const data = await fetchFavicon(domain);
if (data === undefined) return; // offline: keep what we had
cache[domain] = { data, at: now };
changed = true;
}));
}
for (const domain of wanted) {
const data = cache[domain]?.data;
if (data) favicons[domain] = data;
}
if (changed) {
try {
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(cache));
} catch {
// the next refresh asks again
}
}
}
return { photos, favicons };
}
/** The picture for `email` in `images`, and whether it is a logo (drawn on white). */
export function avatarImageFor(
images: AvatarImages | undefined,
email: string | undefined,
): { src: string; logo: boolean } | null {
if (!images || !email) return null;
const key = email.trim().toLowerCase();
const photo = images.photos[key];
if (photo) return { src: photo, logo: false };
const domain = getFaviconDomain(key);
const logo = domain ? images.favicons[domain] : undefined;
return logo ? { src: logo, logo: true } : null;
}
+525
View File
@@ -0,0 +1,525 @@
// Builds the widget snapshot. Mail comes from JMAP on a client of the widgets'
// own (./jmap.ts). Calendar, tasks, birthdays, files and scheduled sends reuse
// the app's helpers, which are bound to the `jmapClient` singleton: they run
// when that singleton is connected to the active account, either because the
// app is running or because no UI has started in this runtime and the refresh
// signed it in itself (see borrowSingleton). Otherwise (the app is up but
// signed in elsewhere, or offline) those parts carry over from the previous
// snapshot; the time-dependent views are still recomputed when a widget draws.
import AsyncStorage from '@react-native-async-storage/async-storage';
import { jmapClient } from '../api/jmap-client';
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 { resolveAvatarImages } from './avatar-images';
import { storeRefresh } from './state';
import { uiStarted } from './ui-presence';
import {
emptySnapshot,
loadSnapshot,
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[],
accountId: 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,
accountId,
...(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,
accountId: string,
): 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, accountId))
.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, accountId);
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, accountId: string, reload: boolean): 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 (reload || store.tasks.length === 0) await store.fetchTasks();
const { tasks, calendars } = useCalendarStore.getState();
const byId = new Map(calendars.map((c) => [c.id, c]));
const items: TaskItem[] = tasks
.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,
accountId,
...(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();
// With no UI in this runtime (a widget or push task started it), sign the
// app's client in to the active account, so calendar and tasks load as
// they do in the app instead of staying as the app last left them.
const borrowed = !singletonServes(activeAccountId) && !uiStarted() && (await borrowSingleton(activeAccountId));
const live = borrowed || singletonServes(activeAccountId);
const client = live ? jmapClient : await openClient(activeAccountId);
const next: WidgetSnapshot = {
...previous,
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.images = undefined;
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, activeAccountId),
buildTasks(next, activeAccountId, borrowed),
buildFiles(next),
buildScheduled(next),
]);
next.calendar = calendar;
next.tasks = tasks;
next.files = files;
next.mail.scheduled = scheduled;
next.appDataAt = now;
}
next.images = await avatarImages(next, useSettingsStore.getState().senderFavicons);
return next;
}
/** Pictures for every avatar the widgets can show (see ./avatar-images.ts). */
async function avatarImages(s: WidgetSnapshot, favicons: boolean): Promise<WidgetSnapshot['images']> {
const emails = [
...s.mail.inbox.map((m) => m.fromEmail),
...s.mail.unified.map((m) => m.fromEmail),
...s.mail.starred.map((m) => m.fromEmail),
...s.mail.favourites.map((p) => p.email),
...s.calendar.events.flatMap((e) => e.participants.map((x) => x.email)),
...s.calendar.birthdays.map((b) => b.email ?? ''),
].filter(Boolean);
try {
const { useContactsStore } = require('../stores/contacts-store') as typeof import('../stores/contacts-store');
const contacts = useContactsStore.getState();
if (!contacts.hydrated) await contacts.hydrate();
return await resolveAvatarImages(emails, { contacts: useContactsStore.getState().contacts ?? [], favicons });
} catch (err) {
console.warn('[widgets] avatar pictures failed', err);
return s.images;
}
}
async function borrowSingleton(registryAccountId: string): Promise<boolean> {
try {
if (!(await jmapClient.loadAccount(registryAccountId))) return false;
} catch {
// Offline, or credentials the app will deal with when it opens.
return false;
}
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
const { useContactsStore } = require('../stores/contacts-store') as typeof import('../stores/contacts-store');
await Promise.all([useCalendarStore.getState().hydrate(), useContactsStore.getState().hydrate()]).catch(() => undefined);
return jmapClient.isConnected;
}
let inflight: Promise<WidgetSnapshot> | null = null;
/**
* 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.
* Changes widget buttons made meanwhile are kept over the result (./state.ts).
*
* `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 startedAt = Date.now();
const previous = (await loadSnapshot()) ?? emptySnapshot();
try {
const next = await buildSnapshot(previous);
await storeRefresh(next, startedAt);
return next;
} catch (err) {
console.warn('[widgets] refresh failed', err);
return previous;
} finally {
inflight = null;
}
})();
return inflight;
}
+40
View File
@@ -0,0 +1,40 @@
{
"$comment": "Home-screen widgets. scripts/sync-android-widgets.mjs turns this into the Android provider classes, provider XML, labels and manifest receivers; run it after editing. cols/rows are launcher cells (Android 2x2 is roughly the iOS small widget, 4x2 medium, 4x4 large).",
"widgets": [
{ "name": "InboxWidget", "label": "Inbox", "description": "Newest messages with sender, time and subject. Taller sizes add avatars and a line of preview.", "cols": 4, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
{ "name": "TriageWidget", "label": "Triage", "description": "One unread message at a time with archive, delete and reply buttons.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
{ "name": "UnreadCountWidget", "label": "Unread count", "description": "Unread count for the inbox, with the latest senders.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
{ "name": "LatestMessageWidget", "label": "Latest message", "description": "Only the newest unread message.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
{ "name": "FolderCountsWidget", "label": "Folder counts", "description": "Unread counts for up to four folders.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
{ "name": "MailShortcutsWidget", "label": "Shortcuts", "description": "Compose, calendar, contacts and files.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail", "static": true },
{ "name": "AllAccountsWidget", "label": "All accounts", "description": "The unified inbox across every signed-in account.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
{ "name": "StarredWidget", "label": "Starred", "description": "Starred messages, newest first.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
{ "name": "FavouritePeopleWidget", "label": "Favourite people", "description": "The people you hear from most, with their unread count.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
{ "name": "MailSearchWidget", "label": "Search mail", "description": "Search bar with your recent searches.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 1, "board": "mail" },
{ "name": "OutboxWidget", "label": "Scheduled and outbox", "description": "Scheduled messages and anything waiting in the outbox.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
{ "name": "TagWidget", "label": "Tag", "description": "Unread count for your busiest tag.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
{ "name": "LastDraftWidget", "label": "Last draft", "description": "Back into the draft you left.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
{ "name": "AgendaWidget", "label": "Agenda", "description": "Upcoming events by day with time, colour and place.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 2, "board": "calendar" },
{ "name": "WeekWidget", "label": "This week", "description": "The week at a glance from 8:00 to 18:00.", "cols": 4, "rows": 2, "minCols": 4, "minRows": 2, "board": "calendar" },
{ "name": "MonthWidget", "label": "Month", "description": "Month grid with a dot per calendar and the next two events.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 3, "board": "calendar" },
{ "name": "TodayEventsWidget", "label": "Today", "description": "Today's date with the rest of today and tomorrow.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "calendar" },
{ "name": "NextEventWidget", "label": "Next event", "description": "What's next, when and where.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "calendar" },
{ "name": "DateNextWidget", "label": "Date and next two", "description": "Today's date with the next two events.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "calendar" },
{ "name": "UpNextWidget", "label": "Up next", "description": "Countdown to the next event with join and running-late buttons.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "calendar" },
{ "name": "InvitationsWidget", "label": "Invitations", "description": "Pending invitations with accept, maybe and decline.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "plans" },
{ "name": "FreeTimeWidget", "label": "Free time", "description": "When you're free this week.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "plans" },
{ "name": "TasksWidget", "label": "Tasks", "description": "Open tasks with tick boxes that work from the home screen.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 2, "board": "plans" },
{ "name": "TaskProgressWidget", "label": "Task progress", "description": "How many tasks are done.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "plans" },
{ "name": "CountdownWidget", "label": "Countdown", "description": "Days until your next all-day event.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "plans" },
{ "name": "BirthdaysWidget", "label": "Birthdays", "description": "Upcoming birthdays from your contacts.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "plans" },
{ "name": "TodayHubWidget", "label": "Today overview", "description": "Next event, newest unread mail and tasks due, in one widget.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 3, "board": "hubs" },
{ "name": "MailAndNextWidget", "label": "Mail and next event", "description": "Unread mail on one side, the next event on the other.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "hubs" },
{ "name": "RecentFilesWidget", "label": "Recent files", "description": "Recently changed items in Files.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "hubs" },
{ "name": "AttachmentsWidget", "label": "Recent attachments", "description": "The newest attachments from your mail.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "hubs" },
{ "name": "VacationWidget", "label": "Vacation responder", "description": "Vacation responder status with an on/off switch.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "hubs" },
{ "name": "StorageWidget", "label": "Storage", "description": "How much of your mailbox quota is used.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "hubs" }
]
}
+96
View File
@@ -0,0 +1,96 @@
// 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, Invitation, MailItem, TaskItem } 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 };
}
/**
* A task tick. It names the state the user asked for, not "the opposite of
* now": a second tap on a drawing that has not caught up yet would otherwise
* undo the first. It carries the task's account and server id so it acts on
* that task even when the widgets have moved on to another account.
*/
export function toggleTask(task: TaskItem): WidgetClick {
return action('toggleTask', {
id: task.id,
done: !task.done,
serverId: task.serverId,
title: task.title,
...(task.accountId ? { accountId: task.accountId } : {}),
...(task.jmapAccountId ? { jmapAccountId: task.jmapAccountId } : {}),
});
}
/** An RSVP; like {@link toggleTask} it carries everything the call needs. */
export function rsvp(inv: Invitation, status: 'accepted' | 'tentative' | 'declined'): WidgetClick {
return action('rsvp', {
id: inv.id,
status,
serverId: inv.serverId,
participantId: inv.participantId,
title: inv.title,
...(inv.accountId ? { accountId: inv.accountId } : {}),
...(inv.jmapAccountId ? { jmapAccountId: inv.jmapAccountId } : {}),
});
}
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('&')}` : '';
}
// Message links carry the thread so the app opens them without asking the
// server first, which also works offline from its cache.
type MessageRef = Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'> & { threadId?: string };
export const links = {
inbox: () => `${base}mail/folder/inbox`,
message: (m: MessageRef) =>
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, thread: m.threadId })}`,
reply: (m: MessageRef) =>
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, thread: m.threadId, 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`,
// `account` is the JMAP account of a shared calendar (as in webmail
// permalinks); `appAccount` the signed-in account the event was shown for.
event: (e: Pick<EventItem, 'serverId' | 'jmapAccountId' | 'accountId'>) =>
`${base}calendar/event/${enc(e.serverId)}${query({ account: e.jmapAccountId, appAccount: e.accountId })}`,
contacts: () => `${base}contacts`,
files: () => `${base}files`,
settings: (tab?: string) => `${base}settings${tab ? `/${enc(tab)}` : ''}`,
};
+209
View File
@@ -0,0 +1,209 @@
// Views that depend on the current time, computed when a widget draws rather
// than when the snapshot was fetched. Pure functions of (snapshot, now), so
// they are unit-tested without the widget runtime.
import { addDays, startOfDay } from './format';
import type { EventItem, TaskItem, WidgetSnapshot } from './snapshot';
const DAY = 86400000;
/** Events overlapping [dayStart, dayStart + 1 day), all-day first, then by start. */
export function eventsOnDay(events: EventItem[], dayStart: number): EventItem[] {
const dayEnd = addDays(dayStart, 1);
return events
.filter((e) => e.start < dayEnd && (e.end > dayStart || (e.end === e.start && e.start >= dayStart)))
.sort((a, b) => (a.allDay === b.allDay ? a.start - b.start : a.allDay ? -1 : 1));
}
/** Timed events that have not ended yet, soonest first. */
export function upcomingTimed(events: EventItem[], now: number): EventItem[] {
return events.filter((e) => !e.allDay && e.end > now).sort((a, b) => a.start - b.start);
}
/** The next timed event (running or upcoming) and whether it has started. */
export function nextEvent(events: EventItem[], now: number): { event: EventItem; running: boolean } | null {
const [event] = upcomingTimed(events, now);
if (!event) return null;
return { event, running: event.start <= now };
}
export interface AgendaDay {
dayStart: number;
events: EventItem[];
}
/**
* Days from today with at least one event still relevant (today drops the
* events that already ended), up to `maxDays` days with events.
*/
export function agendaDays(events: EventItem[], now: number, maxDays: number, horizonDays = 14): AgendaDay[] {
const out: AgendaDay[] = [];
const today = startOfDay(now);
for (let i = 0; i < horizonDays && out.length < maxDays; i++) {
const day = addDays(today, i);
const list = eventsOnDay(events, day).filter((e) => i > 0 || e.allDay || e.end > now);
if (list.length > 0) out.push({ dayStart: day, events: list });
}
return out;
}
/** Start of the week containing `ms` for the given first weekday (0 Sun, 1 Mon). */
export function startOfWeek(ms: number, weekStart: 0 | 1): number {
const day = startOfDay(ms);
const dow = new Date(day).getDay();
const diff = (dow - weekStart + 7) % 7;
return addDays(day, -diff);
}
export interface MonthCell {
dayStart: number;
day: number;
inMonth: boolean;
isToday: boolean;
/** Distinct calendar colours with events that day, at most three. */
dots: string[];
}
/** The month containing `now` as whole weeks (5 or 6 rows). */
export function monthGrid(events: EventItem[], now: number, weekStart: 0 | 1): MonthCell[][] {
const first = new Date(now);
first.setDate(1);
first.setHours(0, 0, 0, 0);
const month = first.getMonth();
const today = startOfDay(now);
let cursor = startOfWeek(first.getTime(), weekStart);
const weeks: MonthCell[][] = [];
do {
const week: MonthCell[] = [];
for (let i = 0; i < 7; i++) {
const d = new Date(cursor);
const dots: string[] = [];
for (const e of eventsOnDay(events, cursor)) {
if (!dots.includes(e.color)) dots.push(e.color);
if (dots.length === 3) break;
}
week.push({
dayStart: cursor,
day: d.getDate(),
inMonth: d.getMonth() === month,
isToday: cursor === today,
dots,
});
cursor = addDays(cursor, 1);
}
weeks.push(week);
} while (new Date(cursor).getMonth() === month);
return weeks;
}
export interface BusyBlock {
/** Fraction of the visible window, 0..1. */
from: number;
to: number;
color: string;
}
/**
* Timed events of one day clipped to [startHour, endHour) as fractions of that
* window - the week and free-time bars.
*/
export function busyBlocks(events: EventItem[], dayStart: number, startHour = 8, endHour = 18): BusyBlock[] {
const winStart = dayStart + startHour * 3600000;
const winEnd = dayStart + endHour * 3600000;
const span = winEnd - winStart;
return eventsOnDay(events, dayStart)
.filter((e) => !e.allDay && e.end > winStart && e.start < winEnd)
.map((e) => ({
from: (Math.max(e.start, winStart) - winStart) / span,
to: (Math.min(e.end, winEnd) - winStart) / span,
color: e.color,
}));
}
export interface FreeTime {
/** The next timed event today, if any. */
next: EventItem | null;
/** True when an event is running right now. */
busyNow: boolean;
/** Longest free stretch left today inside working hours, if any. */
longestGap: { from: number; to: number } | null;
}
export function freeTime(events: EventItem[], now: number, startHour = 8, endHour = 18): FreeTime {
const today = startOfDay(now);
const winEnd = today + endHour * 3600000;
const timed = eventsOnDay(events, today).filter((e) => !e.allDay);
const busyNow = timed.some((e) => e.start <= now && e.end > now);
const next = timed.find((e) => e.start > now) ?? null;
let cursor = Math.max(now, today + startHour * 3600000);
let longest: { from: number; to: number } | null = null;
for (const e of timed.filter((x) => x.end > cursor).sort((a, b) => a.start - b.start)) {
if (e.start > cursor) {
const gap = { from: cursor, to: Math.min(e.start, winEnd) };
if (gap.to > gap.from && (!longest || gap.to - gap.from > longest.to - longest.from)) longest = gap;
}
cursor = Math.max(cursor, e.end);
if (cursor >= winEnd) break;
}
if (cursor < winEnd) {
const gap = { from: cursor, to: winEnd };
if (!longest || gap.to - gap.from > longest.to - longest.from) longest = gap;
}
return { next, busyNow, longestGap: longest };
}
export type DueKind = 'overdue' | 'today' | 'tomorrow' | 'later' | 'none';
export function dueKind(task: TaskItem, now: number): DueKind {
if (task.due === undefined) return 'none';
const today = startOfDay(now);
const dueDay = startOfDay(task.due);
if (task.dueHasTime ? task.due < now : dueDay < today) return 'overdue';
if (dueDay === today) return 'today';
if (dueDay === addDays(today, 1)) return 'tomorrow';
return 'later';
}
/** Open tasks first (overdue, then by due date, undated last), completed after. */
export function sortTasks(tasks: TaskItem[], now: number): TaskItem[] {
const rank: Record<DueKind, number> = { overdue: 0, today: 1, tomorrow: 2, later: 3, none: 4 };
return [...tasks].sort((a, b) => {
if (a.done !== b.done) return a.done ? 1 : -1;
const ra = rank[dueKind(a, now)];
const rb = rank[dueKind(b, now)];
if (ra !== rb) return ra - rb;
return (a.due ?? Number.MAX_SAFE_INTEGER) - (b.due ?? Number.MAX_SAFE_INTEGER);
});
}
/** Tasks that count for "today": overdue, due today, or completed today-ish. */
export function tasksDueNow(tasks: TaskItem[], now: number): TaskItem[] {
return tasks.filter((t) => {
const kind = dueKind(t, now);
return kind === 'overdue' || kind === 'today';
});
}
/**
* The event a countdown should point at: the next all-day or multi-day event
* that starts after today, else the next timed event on a later day.
*/
export function countdownTarget(events: EventItem[], now: number): EventItem | null {
const tomorrow = addDays(startOfDay(now), 1);
const later = events.filter((e) => e.start >= tomorrow).sort((a, b) => a.start - b.start);
return later.find((e) => e.allDay || e.end - e.start >= DAY) ?? later[0] ?? null;
}
export function daysUntil(ms: number, now: number): number {
return Math.round((startOfDay(ms) - startOfDay(now)) / DAY);
}
/** Birthdays in the next `days` days, soonest first. */
export function upcomingBirthdays(snapshot: WidgetSnapshot, now: number, days = 45) {
const today = startOfDay(now);
const until = addDays(today, days);
return snapshot.calendar.birthdays
.filter((b) => b.date >= today && b.date < until)
.sort((a, b) => a.date - b.date);
}
+148
View File
@@ -0,0 +1,148 @@
// 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;
/** A count for a big number: as is below 10,000, then compact ("12K"). */
count: (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]}`;
},
count: (n) => {
if (n < 10000) return String(n);
// Not Intl's compact notation: German leaves thousands unshortened
// ("10.007"), which is what has to fit.
const [value, unit] = n >= 1_000_000 ? [n / 1_000_000, 'M'] : [n / 1000, 'K'];
const digits = value < 100 ? 1 : 0;
let text: string;
try {
text = new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(value);
} catch {
text = value.toFixed(digits).replace(/\.0$/, '');
}
return `${text}${unit}`;
},
};
}
+100
View File
@@ -0,0 +1,100 @@
// Tabler Icons (MIT), the set the webmail draws since it moved off Lucide.
// Path data is copied from @tabler/icons tabler-nodes-outline.json so the
// widgets use the same glyphs as the web UI. Widgets render them through
// SvgWidget, which rasterises an SVG string.
const OUTLINE = {
mail: '<path d="M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10"/><path d="M3 7l9 6l9 -6"/>',
inbox: '<path d="M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12"/><path d="M4 13h3l3 3h4l3 -3h3"/>',
send: '<path d="M10 14l11 -11"/><path d="M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5"/>',
file: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/>',
fileText: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/><path d="M9 9l1 0"/><path d="M9 13l6 0"/><path d="M9 17l6 0"/>',
fileSpreadsheet: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/><path d="M8 11h8v7h-8l0 -7"/><path d="M8 15h8"/><path d="M11 11v7"/>',
presentation: '<path d="M3 4l18 0"/><path d="M4 4v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-10"/><path d="M12 16l0 4"/><path d="M9 20l6 0"/><path d="M8 12l3 -3l2 2l3 -3"/>',
photo: '<path d="M15 8h.01"/><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12"/><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"/><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"/>',
folder: '<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2"/>',
calendarClock: '<path d="M10.5 21h-4.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v3"/><path d="M16 3v4"/><path d="M8 3v4"/><path d="M4 11h10"/><path d="M14 18a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"/><path d="M18 16.5v1.5l.5 .5"/>',
calendar: '<path d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12"/><path d="M16 3v4"/><path d="M8 3v4"/><path d="M4 11h16"/><path d="M11 15h1"/><path d="M12 15v3"/>',
calendarPlain: '<path d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12"/><path d="M16 3v4"/><path d="M8 3v4"/><path d="M4 11h16"/>',
archive: '<path d="M3 6a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2"/><path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10"/><path d="M10 12l4 0"/>',
trash: '<path d="M4 7l16 0"/><path d="M10 11l0 6"/><path d="M14 11l0 6"/><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"/><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"/>',
ban: '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M5.7 5.7l12.6 12.6"/>',
search: '<path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/>',
edit: '<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415"/><path d="M16 5l3 3"/>',
addressBook: '<path d="M20 6v12a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2"/><path d="M10 16h6"/><path d="M11 11a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M4 8h3"/><path d="M4 12h3"/><path d="M4 16h3"/>',
server: '<path d="M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2"/><path d="M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2"/><path d="M7 8l0 .01"/><path d="M7 16l0 .01"/><path d="M11 8h6"/><path d="M11 16h6"/>',
paperclip: '<path d="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"/>',
star: '<path d="M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873l-6.158 -3.245"/>',
message: '<path d="M8 9h8"/><path d="M8 13h6"/><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12"/>',
mapPin: '<path d="M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/><path d="M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0"/>',
video: '<path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4"/><path d="M3 8a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2l0 -8"/>',
clock: '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/><path d="M12 7v5l3 3"/>',
check: '<path d="M5 12l5 5l10 -10"/>',
plus: '<path d="M12 5l0 14"/><path d="M5 12l14 0"/>',
chevronLeft: '<path d="M15 6l-6 6l6 6"/>',
chevronRight: '<path d="M9 6l6 6l-6 6"/>',
users: '<path d="M5 7a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"/><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><path d="M21 21v-2a4 4 0 0 0 -3 -3.85"/>',
lock: '<path d="M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6"/><path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0"/><path d="M8 11v-4a4 4 0 1 1 8 0v4"/>',
cake: '<path d="M3 20h18v-8a3 3 0 0 0 -3 -3h-12a3 3 0 0 0 -3 3v8"/><path d="M3 14.803c.312 .135 .654 .204 1 .197a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1c.35 .007 .692 -.062 1 -.197"/><path d="M12 4l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737"/>',
beach: '<path d="M17.553 16.75a7.5 7.5 0 0 0 -10.606 0"/><path d="M18 3.804a6 6 0 0 0 -8.196 2.196l10.392 6a6 6 0 0 0 -2.196 -8.196"/><path d="M16.732 10c1.658 -2.87 2.225 -5.644 1.268 -6.196c-.957 -.552 -3.075 1.326 -4.732 4.196"/><path d="M15 9l-3 5.196"/><path d="M3 19.25a2.4 2.4 0 0 1 1 -.25a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 1 .25"/>',
reply: '<path d="M9 14l-4 -4l4 -4"/><path d="M5 10h11a4 4 0 1 1 0 8h-1"/>',
checklist: '<path d="M9.615 20h-2.615a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8"/><path d="M14 19l2 2l4 -4"/><path d="M9 8h4"/><path d="M9 12h2"/>',
upload: '<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"/><path d="M7 9l5 -5l5 5"/><path d="M12 4l0 12"/>',
refresh: '<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4"/><path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/>',
tag: '<path d="M6.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/><path d="M3 6v5.172a2 2 0 0 0 .586 1.414l7.71 7.71a2.41 2.41 0 0 0 3.408 0l5.592 -5.592a2.41 2.41 0 0 0 0 -3.408l-7.71 -7.71a2 2 0 0 0 -1.414 -.586h-5.172a3 3 0 0 0 -3 3"/>',
} as const;
const FILLED = {
star: '<path d="M8.243 7.34l-6.38 .925l-.113 .023a1 1 0 0 0 -.44 1.684l4.622 4.499l-1.09 6.355l-.013 .11a1 1 0 0 0 1.464 .944l5.706 -3l5.693 3l.1 .046a1 1 0 0 0 1.352 -1.1l-1.091 -6.355l4.624 -4.5l.078 -.085a1 1 0 0 0 -.633 -1.62l-6.38 -.926l-2.852 -5.78a1 1 0 0 0 -1.794 0l-2.853 5.78z"/>',
tag: '<path d="M11.172 2a3 3 0 0 1 2.121 .879l7.71 7.71a3.41 3.41 0 0 1 0 4.822l-5.592 5.592a3.41 3.41 0 0 1 -4.822 0l-7.71 -7.71a3 3 0 0 1 -.879 -2.121v-5.172a4 4 0 0 1 4 -4zm-3.672 3.5a2 2 0 0 0 -1.995 1.85l-.005 .15a2 2 0 1 0 2 -2"/>',
} as const;
export type IconName = keyof typeof OUTLINE;
export type FilledIconName = keyof typeof FILLED;
/** Splits `#rrggbbaa` into an SVG colour plus opacity; rgba() is passed through. */
function paint(color: string): { value: string; opacity: string } {
const m = /^#([0-9a-f]{6})([0-9a-f]{2})$/i.exec(color);
if (m) return { value: `#${m[1]}`, opacity: (parseInt(m[2], 16) / 255).toFixed(3) };
return { value: color, opacity: '1' };
}
/** Outline Tabler icon as an SVG string (24 x 24 viewBox, stroke 2 like the web UI). */
export function icon(name: IconName, color: string, strokeWidth = 2): string {
const { value, opacity } = paint(color);
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" ` +
`stroke="${value}" stroke-opacity="${opacity}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round">` +
`${OUTLINE[name]}</svg>`
);
}
/** Filled Tabler icon (star, tag) as an SVG string. */
export function filledIcon(name: FilledIconName, color: string): string {
const { value, opacity } = paint(color);
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" ` +
`fill="${value}" fill-opacity="${opacity}">${FILLED[name]}</svg>`
);
}
/**
* A progress ring (quota, task progress, countdown). `fraction` is clamped to
* 0..1; the arc starts at 12 o'clock like the webmail's quota ring.
*/
export function ring(fraction: number, track: string, fill: string, strokeWidth = 6): string {
const f = Math.max(0, Math.min(1, Number.isFinite(fraction) ? fraction : 0));
const r = 32 - strokeWidth / 2;
const c = 2 * Math.PI * r;
const t = paint(track);
const p = paint(fill);
const arc = f > 0
? `<circle cx="32" cy="32" r="${r}" fill="none" stroke="${p.value}" stroke-opacity="${p.opacity}" stroke-width="${strokeWidth}" ` +
`stroke-linecap="round" stroke-dasharray="${(f * c).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 32 32)"/>`
: '';
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">` +
`<circle cx="32" cy="32" r="${r}" fill="none" stroke="${t.value}" stroke-opacity="${t.opacity}" stroke-width="${strokeWidth}"/>` +
`${arc}</svg>`
);
}
+451
View File
@@ -0,0 +1,451 @@
// 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', 'threadId', '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,
threadId: e.threadId,
accountId: registryAccountId,
fromName: e.from?.[0]?.name || e.from?.[0]?.email || '',
receivedAt: Date.parse(e.receivedAt) || 0,
files: realAttachments(e.attachments as Attachment[] | undefined)
.map((a) => ({ name: a.name ?? '', type: a.type || 'application/octet-stream' })),
}))
.filter((g) => g.files.length > 0)
.sort((a, b) => b.receivedAt - a.receivedAt)
.slice(0, 3);
const tagCounts = await fetchTagCounts(client, tagKeywords, excluded, registryAccountId);
const { quota, vacation } = await fetchAccountStatus(client);
return {
mailboxes,
folders: folderCounts(mailboxes),
inbox: inboxItems,
starred,
starredCount: responseOf(listRes, 'qs')?.total ?? starred.length,
drafts: draftItems,
draftCount: responseOf(listRes, 'qd')?.total ?? draftItems.length,
favourites: rankPeople((responseOf(listRes, 'gp')?.list ?? []) as Email[], selfEmails),
attachments,
tagCounts,
quota,
vacation,
selfEmails,
};
}
async function fetchTagCounts(
client: JMAPClient,
keywords: string[],
excluded: string[],
registryAccountId: string,
): Promise<MailSection['tagCounts']> {
const out: MailSection['tagCounts'] = new Map();
const wanted = keywords.slice(0, 8);
if (wanted.length === 0) return out;
const accountId = client.accountId;
const base = (keyword: string) =>
excluded.length ? [{ hasKeyword: keyword }, { inMailboxOtherThan: excluded }] : [{ hasKeyword: keyword }];
const calls: Call[] = [];
wanted.forEach((keyword, i) => {
calls.push(['Email/query', {
accountId,
filter: { operator: 'AND', conditions: base(keyword) },
sort: NEWEST_FIRST, limit: 1, calculateTotal: true,
}, `t${i}`]);
calls.push(['Email/get', {
accountId,
'#ids': { resultOf: `t${i}`, name: 'Email/query', path: '/ids' },
properties: LIST_PROPERTIES,
}, `l${i}`]);
calls.push(['Email/query', {
accountId,
filter: { operator: 'AND', conditions: [...base(keyword), { notKeyword: '$seen' }] },
limit: 0, calculateTotal: true,
}, `u${i}`]);
});
const max = Math.max(3, client.getMaxCallsInRequest() - (client.getMaxCallsInRequest() % 3));
for (let start = 0; start < calls.length; start += max) {
const res = await client.request(calls.slice(start, start + max));
wanted.forEach((keyword, i) => {
const total = responseOf(res, `t${i}`)?.total;
if (total === undefined) return;
const latest = (responseOf(res, `l${i}`)?.list ?? [])[0] as Email | undefined;
out.set(keyword, {
total,
unread: responseOf(res, `u${i}`)?.total ?? 0,
latest: latest ? toMailItem(latest, registryAccountId) : undefined,
});
});
}
return out;
}
async function fetchAccountStatus(client: JMAPClient): Promise<{ quota: QuotaState | null; vacation: VacationState | null }> {
const accountId = client.accountId;
const calls: Call[] = [];
const using: string[] = [CAPABILITIES.CORE];
if (client.hasAccountCapability(CAPABILITIES.QUOTA)) {
calls.push(['Quota/get', { accountId }, 'q']);
using.push(CAPABILITIES.QUOTA);
}
if (client.hasAccountCapability(CAPABILITIES.VACATION)) {
calls.push(['VacationResponse/get', { accountId, ids: ['singleton'] }, 'v']);
using.push(CAPABILITIES.MAIL, CAPABILITIES.VACATION);
}
if (calls.length === 0) return { quota: null, vacation: null };
try {
const res = await client.request(calls, using);
const quotas = (responseOf(res, 'q')?.list ?? []) as Array<{
resourceType?: string; scope?: string; used?: number; hardLimit?: number; limit?: number; types?: string[];
}>;
const coversMail = (q: (typeof quotas)[number]) => !q.types?.length || q.types.some((t) => t === 'Email' || t === 'Mail');
const mailQuota = quotas.find((q) => q.resourceType === 'octets' && coversMail(q))
?? quotas.find((q) => q.resourceType === 'mail' || q.scope === 'mail');
const limit = mailQuota ? mailQuota.hardLimit ?? mailQuota.limit ?? 0 : 0;
const v = (responseOf(res, 'v')?.list ?? [])[0] as
| { isEnabled?: boolean; fromDate?: string | null; toDate?: string | null; subject?: string | null }
| undefined;
return {
quota: mailQuota && limit > 0 ? { used: mailQuota.used ?? 0, limit } : null,
vacation: v
? {
enabled: !!v.isEnabled,
...(v.fromDate ? { from: Date.parse(v.fromDate) } : {}),
...(v.toDate ? { to: Date.parse(v.toDate) } : {}),
...(v.subject ? { subject: v.subject } : {}),
}
: null,
};
} catch {
return { quota: null, vacation: null };
}
}
/** Newest inbox messages of one account, for the all-accounts widget. */
export async function fetchInboxPreview(client: JMAPClient, registryAccountId: string, limit = 5): Promise<{
items: MailItem[];
unread: number;
}> {
const accountId = client.accountId;
const boxes = await client.request([
['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'mq'],
['Mailbox/get', {
accountId,
'#ids': { resultOf: 'mq', name: 'Mailbox/query', path: '/ids' },
properties: MAILBOX_PROPERTIES,
}, 'mg'],
]);
const inbox = (responseOf(boxes, 'mg')?.list ?? [])[0] as Mailbox | undefined;
if (!inbox) return { items: [], unread: 0 };
const res = await client.request([
['Email/query', { accountId, filter: { inMailbox: inbox.id }, sort: NEWEST_FIRST, collapseThreads: true, limit }, 'q'],
['Email/get', { accountId, '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, properties: LIST_PROPERTIES }, 'g'],
]);
return {
items: ((responseOf(res, 'g')?.list ?? []) as Email[]).map((e) => toMailItem(e, registryAccountId)),
unread: inbox.unreadEmails ?? 0,
};
}
// ── Actions ──────────────────────────────────────────────────────────────
function emailAccount(client: JMAPClient, jmapAccountId?: string): string {
return jmapAccountId ?? client.accountId;
}
async function setEmail(client: JMAPClient, accountId: string, update: Record<string, Record<string, unknown>>): Promise<boolean> {
const res = await client.request([['Email/set', { accountId, update }, 's']]);
const body = responseOf(res, 's');
return !!body && !body.notUpdated;
}
export async function markRead(client: JMAPClient, emailId: string, jmapAccountId?: string): Promise<boolean> {
return setEmail(client, emailAccount(client, jmapAccountId), { [emailId]: { 'keywords/$seen': true } });
}
/**
* Move a message out of its mailboxes into the archive (or trash). Reads the
* message's current mailboxes first so the patch only drops those.
*/
export async function moveTo(
client: JMAPClient,
emailId: string,
target: 'archive' | 'trash',
jmapAccountId?: string,
): Promise<boolean> {
const accountId = emailAccount(client, jmapAccountId);
const res = await client.request([
['Mailbox/get', { accountId, properties: MAILBOX_PROPERTIES }, 'm'],
['Email/get', { accountId, ids: [emailId], properties: ['id', 'mailboxIds'] }, 'e'],
]);
const mailboxes = (responseOf(res, 'm')?.list ?? []) as Mailbox[];
const email = (responseOf(res, 'e')?.list ?? [])[0] as Pick<Email, 'id' | 'mailboxIds'> | undefined;
const destination = target === 'archive' ? findArchiveMailbox(mailboxes) : findTrashMailbox(mailboxes);
if (!email || !destination) return false;
const patch: Record<string, unknown> = { [`mailboxIds/${destination.id}`]: true };
for (const id of Object.keys(email.mailboxIds ?? {})) {
if (id !== destination.id) patch[`mailboxIds/${id}`] = null;
}
if (target === 'trash') patch['keywords/$seen'] = true;
return setEmail(client, accountId, { [emailId]: patch });
}
export async function rsvp(
client: JMAPClient,
eventId: string,
participantId: string,
status: 'accepted' | 'tentative' | 'declined',
jmapAccountId?: string,
): Promise<boolean> {
const accountId = jmapAccountId ?? client.getPrimaryAccountId(CAPABILITIES.CALENDARS);
const res = await client.request([
['CalendarEvent/set', {
accountId,
update: { [eventId]: { [`participants/${participantId}/participationStatus`]: status } },
sendSchedulingMessages: true,
}, 's'],
], [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]);
const body = responseOf(res, 's');
return !!body && !body.notUpdated;
}
/** Same flip as calendar-store's toggleTaskComplete; progressUpdated is left to the server. */
export async function setTaskDone(
client: JMAPClient,
taskId: string,
done: boolean,
jmapAccountId?: string,
): Promise<boolean> {
const accountId = jmapAccountId ?? client.getPrimaryAccountId(CAPABILITIES.CALENDARS);
const res = await client.request([
['CalendarEvent/set', {
accountId,
update: {
[taskId]: done
? { progress: 'completed', percentComplete: 100 }
: { progress: 'needs-action', percentComplete: 0 },
},
}, 's'],
], [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]);
const body = responseOf(res, 's');
return !!body && !body.notUpdated;
}
File diff suppressed because it is too large Load Diff
+798
View File
@@ -0,0 +1,798 @@
// 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 { links, open, toggleTask } 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, NOTICE_HEIGHT, NoticeBar } from '../parts';
import { noticeFor } from '../pending';
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 = 48;
/**
* 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,
}}
>
{/* 48dp touch target around the 20dp box, which sits at the 14dp inset. */}
<FlexWidget
{...toggleTask(task)}
accessibilityLabel={task.done ? f.t('widgets.tasks.mark_incomplete', 'Mark as not done') : f.t('widgets.tasks.mark_complete', 'Mark as done')}
style={{ width: 48, height: 48, 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>
<FlexWidget
{...open(links.calendar())}
style={{ flex: 1, height: 'match_parent', flexDirection: 'row', alignItems: 'center', paddingRight: 14 }}
>
<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>
</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.
const notice = noticeFor(s.notice, 'toggleTask');
let budget = height - 2 - HUB_HEADER_HEIGHT - (notice ? NOTICE_HEIGHT + 12 : 0);
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>
{notice ? (
<FlexWidget style={{ width: 'match_parent', paddingHorizontal: 8, paddingTop: 6, paddingBottom: 6 }}>
<NoticeBar p={p} f={f} notice={notice} />
</FlexWidget>
) : null}
{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, width, 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 = f.count(unread).length;
const big = (tight ? 28 : 36) - (digits >= 4 ? 8 : 0);
// "unread" sits beside the number where it fits (a half-width column),
// under it otherwise (German, "ungelesen").
const unreadLabel = f.t('widgets.mail.unread', 'unread');
const labelBeside = digits * big * 0.6 + 4 + textWidth(unreadLabel, 13) <= (width - 2) / 2 - 2 * pad;
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,
borderBottomLeftRadius: WIDGET_RADIUS,
}}
>
<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={f.count(unread)} maxLines={1} style={{ color: p.fg, fontSize: big, fontWeight: '700' }} />
{labelBeside ? <Spacer size={4} horizontal /> : null}
{/* Lines the small label up with the big number's baseline. */}
{labelBeside ? (
<Txt text={unreadLabel} color={p.muted} size={13} style={{ paddingBottom: Math.round((big - 13) * 0.27) }} />
) : null}
</FlexWidget>
{labelBeside ? null : <Txt text={unreadLabel} color={p.muted} size={13} />}
<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" size={40} 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, threadId: g.threadId, 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. */
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;
// Icon 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 />
{/* Not a switch: the widget cannot turn replies on or off, it opens
the settings that do. The pill below says which state it is in. */}
<Icon name="chevronRight" color={p.muted} size={16} />
</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>
);
};
+342
View File
@@ -0,0 +1,342 @@
// 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, NoticeBar } from '../parts';
import { noticeFor } from '../pending';
import {
Avatar,
Button,
Fab,
GhostIcon,
Header,
Icon,
Placeholder,
Spacer,
Surface,
Txt,
} from '../primitives';
import type { Fmt } from '../format';
import { avatarImageFor } from '../avatar-images';
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, f, unread, total, short }: { p: WidgetPalette; f: Fmt; unread: number; total: number; short?: boolean }) {
return (
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
<Txt text={f.count(unread)} color={p.fg} size={12} weight="600" />
{short ? null : <Txt text={` / ${f.count(total)}`} color={p.muted} size={12} />}
</FlexWidget>
);
}
export const InboxLayout: Layout = ({ s, p, f, now, width, height }) => {
const compact = height < 190;
// At two cells wide the title, count, search and compose do not fit.
const narrow = width < 240;
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' }}>
<Spacer size={8} horizontal />
<CountText p={p} f={f} unread={unread} total={total} short={narrow} />
{compact || narrow ? null : <GhostIcon p={p} iconName="search" size={headerHeight} click={open(links.search())} />}
<Spacer size={6} horizontal />
<Fab p={p} iconName="edit" size={compact ? 28 : 32} slop={compact ? 5 : 6} 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} images={s.images} />
))
)}
</Surface>
);
};
/**
* What a labelled button needs: icon, gap, label and its border. A glyph of
* the 13sp label averages a little under half its size; measured against the
* emulator, English fits at three cells and German does not.
*/
const buttonWidth = (label: string) => 15 + 6 + Math.ceil(label.length * 13 * 0.47) + 2;
export const TriageLayout: Layout = ({ s, p, f, now, width, height, 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 };
const notice = noticeFor(s.notice, 'archive', 'trash');
// Long labels (German at three cells) drop to icons instead of being cut:
// first Archive and Delete, then Reply too.
const labels = {
archive: f.t('widgets.mail.archive', 'Archive'),
trash: f.t('widgets.mail.delete', 'Delete'),
reply: f.t('widgets.mail.reply', 'Reply'),
};
const room = width - 2 - 24 - 32 - 18;
const allFit = buttonWidth(labels.archive) + buttonWidth(labels.trash) + buttonWidth(labels.reply) <= room;
const replyFits = 2 * 32 + buttonWidth(labels.reply) <= room;
return (
// The buttons draw 32dp tall in a 48dp tap area (slop 8), which takes the
// gap above them and 8dp of the bottom padding.
<Surface p={p} style={{ padding: 12, paddingBottom: 4 }}>
<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} image={avatarImageFor(s.images, current.fromEmail)} />
<Spacer size={10} horizontal />
{/* A long name is cut, not the time and position after it. */}
<FlexWidget style={{ flex: 1 }}>
<Txt text={current.fromName || current.fromEmail} color={p.fg} size={14} weight="700" />
</FlexWidget>
<Spacer size={6} horizontal />
<Txt text={f.listDate(current.receivedAt, now)} color={p.fg} size={12} weight="600" />
<Spacer size={8} horizontal />
<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" />
{/* The notice takes the preview's place, so the card fits at its smallest. */}
{current.preview && !notice ? (
// Two lines at the default size; a taller card shows more of the message.
<Txt text={current.preview} color={p.muted} size={13} lines={Math.max(2, Math.floor((height - 125) / 18))} />
) : null}
</FlexWidget>
{notice ? <NoticeBar p={p} f={f} notice={notice} /> : null}
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
<Button p={p} label={allFit ? labels.archive : undefined} iconName="archive" flex={1} slop={8} click={action('archive', target)} />
<Spacer size={6} horizontal />
<Button p={p} label={allFit ? labels.trash : undefined} iconName="trash" flex={1} slop={8} click={action('trash', target)} />
<Spacer size={6} horizontal />
<Button p={p} label={allFit || replyFits ? labels.reply : undefined} iconName="reply" variant="primary" flex={1} slop={8} click={open(links.reply(current))} />
<Spacer size={6} horizontal />
<Button p={p} iconName="chevronRight" slop={8} click={action('triageNext', { widgetId, id: current.id })} />
</FlexWidget>
</Surface>
);
};
export const UnreadCountLayout: Layout = ({ s, p, f, width }) => {
const { unread, total } = inboxCounts(s);
const big = f.count(unread);
const bigSize = big.length <= 3 ? 44 : big.length === 4 ? 38 : 32;
// The total goes when the two do not fit on one line next to each other.
const showTotal = big.length * bigSize * 0.6 + (f.count(total).length + 2) * 15 * 0.55 + 4 <= width - 30;
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={big} maxLines={1} style={{ color: p.fg, fontSize: bigSize, fontWeight: '700' }} />
{showTotal ? <Spacer size={4} horizontal /> : null}
{showTotal ? (
<TextWidget text={`/ ${f.count(total)}`} maxLines={1} style={{ color: p.muted, fontSize: 15, paddingBottom: 8 }} />
) : null}
</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} image={avatarImageFor(s.images, m.fromEmail)} />
</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, height }) => {
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} image={avatarImageFor(s.images, latest.fromEmail)} />
<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} />
{/* Resized taller, the card has room for the start of the message. */}
{latest.preview && height >= 260 ? <Spacer size={4} /> : null}
{latest.preview && height >= 260 ? (
<Txt text={latest.preview} color={p.muted} size={13} lines={Math.floor((height - 190) / 18)} />
) : null}
<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 });
}
if (rows.length === 0) {
return (
<Surface p={p}>
<Placeholder p={p} iconName="inbox" title={f.t('widgets.mail.no_folders', 'No folders to show')} click={open(links.inbox())} />
</Surface>
);
}
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>
);
};
+744
View File
@@ -0,0 +1,744 @@
// 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 { avatarImageFor } from '../avatar-images';
import type { AccountSummary, AvatarImages, 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}
images={s.images}
/>
))
)}
</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}
images={s.images}
/>
))
)}
</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,
images,
}: {
p: WidgetPalette;
f: Fmt;
person: Person;
avatar: number;
showTime: boolean;
now: number;
images?: AvatarImages;
}) {
// 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} image={avatarImageFor(images, person.email)} />
</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}
images={s.images}
/>
) : (
<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={f.count(count)} maxLines={1} style={{ color: p.fg, fontSize: f.count(count).length <= 4 ? 44 : 34, 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>
);
};
+685
View File
@@ -0,0 +1,685 @@
// 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 { links, open, rsvp } 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 { NOTICE_HEIGHT, NoticeBar, TASK_ROW_HEIGHT, TaskRow } from '../parts';
import { noticeFor } from '../pending';
import {
Avatar,
Button,
Dot,
Fab,
Header,
Icon,
Placeholder,
Spacer,
Surface,
Txt,
} from '../primitives';
import { avatarImageFor } from '../avatar-images';
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;
// Tap area of 48dp around the buttons; it takes the bottom padding.
const slop = Math.min(pad, Math.round((48 - buttonHeight) / 2));
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') => rsvp(inv, status);
const notice = noticeFor(s.notice, 'rsvp');
return (
<Surface p={p} style={{ padding: pad, paddingBottom: pad - slop }}>
<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 && !notice ? <MetaLine p={p} iconName="users" text={people} /> : null}
</FlexWidget>
</FlexWidget>
<Spacer size={8} />
<Spacer />
{notice ? <NoticeBar p={p} f={f} notice={notice} /> : null}
{notice ? <Spacer size={6} /> : null}
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
<Button
p={p}
variant="primary"
label={f.t('widgets.invites.accept', 'Accept')}
flex={1}
height={buttonHeight}
slop={slop}
click={reply('accepted')}
/>
<Spacer size={6} horizontal />
<Button p={p} label={f.t('widgets.invites.maybe', 'Maybe')} flex={1} height={buttonHeight} slop={slop} click={reply('tentative')} />
<Spacer size={6} horizontal />
<Button p={p} label={f.t('widgets.invites.decline', 'Decline')} flex={1} height={buttonHeight} slop={slop} 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 (
// The full-width button draws tier.button tall; its tap area also takes
// the bottom padding (slop).
<Surface p={p} style={{ paddingHorizontal: 14, paddingTop: tier.pad, paddingBottom: tier.button ? 0 : 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>
<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}
// Above, the tap area takes the gap and any height to spare; below, the padding.
slop={{ top: Math.min(tier.pad, tier.gap + Math.max(0, height - freeTierHeight(tier))), bottom: tier.pad }}
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 notice = noticeFor(s.notice, 'toggleTask');
const noticeSpace = notice ? NOTICE_HEIGHT + 12 : 0;
const fit = Math.max(1, Math.floor((height - 2 - headerHeight - 1 - noticeSpace) / 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} slop={compact ? 4 : 6} click={open(links.calendar())} />}
/>
{notice ? (
<FlexWidget style={{ width: 'match_parent', paddingHorizontal: 8, paddingTop: 6, paddingBottom: 6 }}>
<NoticeBar p={p} f={f} notice={notice} />
</FlexWidget>
) : null}
{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} image={avatarImageFor(s.images, b.email)} />
<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')}
// The row is 50dp; the tap area takes its full height.
slop={Math.floor((BIRTHDAY_ROW_HEIGHT - 32) / 2)}
click={open(links.compose({ to: wish, subject: f.t('widgets.birthdays.wishes_subject', 'Happy birthday!') }))}
/>
) : null}
</FlexWidget>
);
})}
</Surface>
);
};
+21
View File
@@ -0,0 +1,21 @@
import type React from 'react';
import type { Fmt } from '../format';
import type { WidgetSnapshot } from '../snapshot';
import type { WidgetPalette } from '../theme';
/** Per-widget state kept between draws (e.g. which message the triage card shows). */
export type WidgetLocalState = Record<string, unknown>;
export interface LayoutProps {
s: WidgetSnapshot;
p: WidgetPalette;
f: Fmt;
now: number;
/** Widget size in dp as the launcher reports it. */
width: number;
height: number;
widgetId: number;
local: WidgetLocalState;
}
export type Layout = (props: LayoutProps) => React.JSX.Element;
+26
View File
@@ -0,0 +1,26 @@
// State that belongs to one placed widget rather than to the data: which
// message the triage card is showing. Keyed by the launcher's widget id and
// dropped when the widget is removed.
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { WidgetLocalState } from './layouts/types';
const key = (widgetId: number) => `widgets:local:${widgetId}`;
export async function loadLocal(widgetId: number): Promise<WidgetLocalState> {
try {
const raw = await AsyncStorage.getItem(key(widgetId));
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' ? (parsed as WidgetLocalState) : {};
} catch {
return {};
}
}
export async function saveLocal(widgetId: number, state: WidgetLocalState): Promise<void> {
await AsyncStorage.setItem(key(widgetId), JSON.stringify(state));
}
export async function removeLocal(widgetId: number): Promise<void> {
await AsyncStorage.removeItem(key(widgetId));
}
+330
View File
@@ -0,0 +1,330 @@
// 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 { avatarImageFor } from './avatar-images';
import { action, links, open, toggleTask, 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 { ActionNotice, AvatarImages, 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,
images,
}: {
p: WidgetPalette;
f: Fmt;
m: MailItem;
now: number;
compact?: boolean;
showAccountDot?: boolean;
accountColor?: string;
last?: boolean;
/** The snapshot's avatar pictures; without them the avatar shows the initials. */
images?: AvatarImages;
}) {
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} image={avatarImageFor(images, m.fromEmail)} /> : 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;
}) {
// A finished task is not overdue: it shows its date.
const due = dueKind(task, now);
const kind = task.done && due === 'overdue' ? 'later' : due;
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 = toggleTask(task);
return (
<FlexWidget
style={{
flexDirection: 'row',
width: 'match_parent',
height: TASK_ROW_HEIGHT,
borderBottomWidth: last ? 0 : 1,
borderBottomColor: p.border,
}}
>
{/* The tick takes the row's full height and 50dp around the 22dp box. */}
<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: 50, height: 'match_parent', justifyContent: 'center', alignItems: 'center' }}
>
<FlexWidget
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>
</FlexWidget>
<FlexWidget
{...open(links.calendar())}
style={{ flex: 1, height: 'match_parent', justifyContent: 'center', paddingRight: 14 }}
>
<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 = 56;
export const NOTICE_HEIGHT = 44;
/**
* A button's change that did not reach the server: says so in the
* destructive tint (webmail `bg-destructive/10 text-destructive`) and repeats
* the tap when tapped.
*/
export function NoticeBar({ p, f, notice }: { p: WidgetPalette; f: Fmt; notice: ActionNotice }) {
const text = notice.action === 'archive'
? f.t('widgets.notice.archive', "Couldn't archive. Tap to try again.")
: notice.action === 'trash'
? f.t('widgets.notice.trash', "Couldn't delete. Tap to try again.")
: notice.action === 'markRead'
? f.t('widgets.notice.mark_read', "Couldn't mark as read. Tap to try again.")
: notice.action === 'rsvp'
? f.t('widgets.notice.rsvp', "Couldn't send your answer. Tap to try again.")
: f.t('widgets.notice.task', "Couldn't update “{title}”. Tap to try again.", {
title: notice.label || f.t('widgets.tasks.no_title', '(no title)'),
});
return (
<FlexWidget
{...action(notice.action, notice.retry)}
style={{
flexDirection: 'row',
alignItems: 'center',
width: 'match_parent',
height: NOTICE_HEIGHT,
paddingHorizontal: 10,
borderRadius: 6,
backgroundColor: p.destructiveBg,
}}
>
<Icon name="refresh" color={p.destructive as string} size={14} />
<Spacer size={8} horizontal />
<FlexWidget style={{ flex: 1 }}>
{/* Two lines: a long task title must not cut off "Tap to try again". */}
<Txt text={text} color={p.destructive} size={12} weight="500" lines={2} />
</FlexWidget>
</FlexWidget>
);
}
+150
View File
@@ -0,0 +1,150 @@
// Changes a widget button made that the stored snapshot does not show yet.
// The snapshot holds what the server last said; these are laid over it when
// a widget draws, so a refresh that read the server before the change landed
// cannot bring an archived message back, and a change the server refused is
// undone by dropping it rather than by hoping the next refresh gets through.
//
// An op is pending until the server answers, then done: it stays until a
// refresh that started after the server took it has replaced the data it
// covers (mail, or the calendar/tasks part that only some refreshes load).
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { WidgetActionName } from './clicks';
import type { ActionNotice, WidgetSnapshot } from './snapshot';
export const PENDING_KEY = 'widgets:pending:v1';
export type PendingChange =
| { kind: 'removeMail'; id: string }
| { kind: 'markRead'; id: string }
// `accountId`: the signed-in account the item belongs to. JMAP ids are
// only unique within an account, so the change must not touch another
// account's item with the same id once the widgets show that account.
| { kind: 'rsvp'; id: string; serverId: string; status: 'accepted' | 'tentative' | 'declined'; accountId?: string }
| { kind: 'task'; id: string; done: boolean; accountId?: string };
export interface PendingOp {
/** Unique per tap. */
key: string;
change: PendingChange;
/** When the tap was made. */
at: number;
/** When the server took the change; absent while it is in flight. */
doneAt?: number;
}
export interface PendingState {
ops: PendingOp[];
/** The last button change that did not reach the server, until it is retried or goes stale. */
notice?: ActionNotice | null;
}
/** A done op the next refreshes never confirmed is dropped after this long. */
export const DONE_TTL_MS = 30 * 60 * 1000;
/** An op still in flight after this long belongs to a task that died. */
export const IN_FLIGHT_TTL_MS = 2 * 60 * 1000;
/** How long a failure notice stays on the widget. */
export const NOTICE_TTL_MS = 10 * 60 * 1000;
export function emptyPending(): PendingState {
return { ops: [] };
}
export async function loadPending(): Promise<PendingState> {
try {
const raw = await AsyncStorage.getItem(PENDING_KEY);
const parsed = raw ? (JSON.parse(raw) as Partial<PendingState>) : null;
return {
ops: Array.isArray(parsed?.ops) ? parsed!.ops : [],
notice: parsed?.notice && typeof parsed.notice === 'object' ? parsed.notice : null,
};
} catch {
return emptyPending();
}
}
export async function savePending(state: PendingState): Promise<void> {
await AsyncStorage.setItem(PENDING_KEY, JSON.stringify(state));
}
const isMail = (c: PendingChange) => c.kind === 'removeMail' || c.kind === 'markRead';
/** Drop what has expired: in-flight ops of a task that died, done ops no refresh confirmed, an old notice. */
export function expire(state: PendingState, now: number): PendingState {
return {
ops: state.ops.filter((o) => (o.doneAt ? now - o.doneAt < DONE_TTL_MS : now - o.at < IN_FLIGHT_TTL_MS)),
notice: state.notice && now - state.notice.at < NOTICE_TTL_MS ? state.notice : null,
};
}
/** Which widget buttons a notice belongs to. */
export function noticeFor(notice: ActionNotice | null | undefined, ...actions: WidgetActionName[]): ActionNotice | null {
return notice && actions.includes(notice.action) ? notice : null;
}
/**
* After a refresh that started at `startedAt` was stored: the done ops it
* covers are now part of the snapshot. Mail is always refreshed; calendar and
* tasks only when `appData` is set.
*/
export function confirmedBy(ops: PendingOp[], startedAt: number, appData: boolean): PendingOp[] {
return ops.filter((o) => !(o.doneAt !== undefined && o.doneAt < startedAt && (appData || isMail(o.change))));
}
/** The snapshot as the widgets should show it: the stored one with every op applied. */
export function applyPending(s: WidgetSnapshot, ops: PendingOp[]): WidgetSnapshot {
if (ops.length === 0) return s;
const next: WidgetSnapshot = {
...s,
mail: {
...s.mail,
folders: s.mail.folders.map((f) => ({ ...f })),
inbox: [...s.mail.inbox],
unified: [...s.mail.unified],
starred: [...s.mail.starred],
},
calendar: { ...s.calendar, events: [...s.calendar.events], invitations: [...s.calendar.invitations] },
tasks: { ...s.tasks, items: [...s.tasks.items] },
};
for (const { change } of ops) {
switch (change.kind) {
case 'removeMail': {
const removed = next.mail.inbox.find((m) => m.id === change.id);
const drop = <T extends { id: string }>(list: T[]) => list.filter((m) => m.id !== change.id);
next.mail.inbox = drop(next.mail.inbox);
next.mail.unified = drop(next.mail.unified);
next.mail.starred = drop(next.mail.starred);
const inbox = next.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);
}
break;
}
case 'markRead': {
const hit = next.mail.inbox.find((m) => m.id === change.id);
const inbox = next.mail.folders.find((f) => f.role === 'inbox');
if (hit?.unread && inbox) inbox.unread = Math.max(0, inbox.unread - 1);
const read = <T extends { id: string; unread: boolean }>(list: T[]) =>
list.map((m) => (m.id === change.id && m.unread ? { ...m, unread: false } : m));
next.mail.inbox = read(next.mail.inbox);
next.mail.unified = read(next.mail.unified);
next.mail.starred = read(next.mail.starred);
break;
}
case 'rsvp':
if (change.accountId && change.accountId !== s.activeAccountId) break;
next.calendar.invitations = next.calendar.invitations.filter(
(i) => i.id !== change.id && i.serverId !== change.serverId,
);
next.calendar.events = next.calendar.events.map((e) =>
(e.serverId === change.serverId && e.myStatus ? { ...e, myStatus: change.status } : e));
break;
case 'task':
if (change.accountId && change.accountId !== s.activeAccountId) break;
next.tasks.items = next.tasks.items.map((t) => (t.id === change.id ? { ...t, done: change.done } : t));
break;
}
}
return next;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+463
View File
@@ -0,0 +1,463 @@
// Building blocks shared by the widget layouts. They mirror the webmail pieces
// the mockups copied: list rows, avatars, pills, event chips, buttons.
// react-native-android-widget turns these into RemoteViews, so only its own
// components (FlexWidget, TextWidget, SvgWidget, OverlapWidget) can appear.
import React from 'react';
import {
FlexWidget,
ImageWidget,
OverlapWidget,
SvgWidget,
TextWidget,
type ColorProp,
type FlexWidgetStyle,
type TextWidgetStyle,
} from 'react-native-android-widget';
import { chipBackground, normalizeHex, type WidgetPalette } from './theme';
import { filledIcon, icon, type FilledIconName, type IconName } from './icons';
import type { WidgetClick } from './clicks';
/** Corner radius of the widget surface; `widget_radius` in android/app/src/main/res/values/dimens.xml. */
export const WIDGET_RADIUS = 16;
/**
* The widget's background. Its edge and the clip to rounded corners come from
* the root layout (android/app/src/main/res/layout/rn_widget.xml), which has the
* launcher's real size; this bitmap is drawn at the reported one and can be cut off.
*/
export function Surface({
p,
children,
style,
click,
}: {
p: WidgetPalette;
children?: React.ReactNode;
style?: FlexWidgetStyle;
click?: WidgetClick;
}) {
return (
<FlexWidget
{...click}
style={{
height: 'match_parent',
width: 'match_parent',
backgroundColor: p.bg,
borderRadius: WIDGET_RADIUS,
overflow: 'hidden',
...style,
}}
>
{children}
</FlexWidget>
);
}
export function Txt({
text,
color,
size = 14,
weight = '400',
lines = 1,
style,
click,
}: {
text: string;
color: ColorProp;
size?: number;
weight?: TextWidgetStyle['fontWeight'];
lines?: number;
style?: TextWidgetStyle;
click?: WidgetClick;
}) {
return (
<TextWidget
{...click}
text={text}
maxLines={lines}
truncate="END"
style={{ color, fontSize: size, fontWeight: weight, ...style }}
/>
);
}
export function Icon({
name,
color,
size = 16,
style,
click,
}: {
name: IconName;
color: string;
size?: number;
style?: FlexWidgetStyle;
click?: WidgetClick;
}) {
return <SvgWidget {...click} svg={icon(name, color)} style={{ width: size, height: size, ...style }} />;
}
export function FilledIcon({ name, color, size = 14 }: { name: FilledIconName; color: string; size?: number }) {
return <SvgWidget svg={filledIcon(name, color)} style={{ width: size, height: size }} />;
}
export function Spacer({ size, horizontal }: { size?: number; horizontal?: boolean }) {
if (size === undefined) return <FlexWidget style={{ flex: 1 }} />;
return <FlexWidget style={horizontal ? { width: size, height: 1 } : { height: size, width: 1 }} />;
}
export function Dot({ color, size = 8 }: { color: ColorProp; size?: number }) {
return <FlexWidget style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
}
export function Divider({ p }: { p: WidgetPalette }) {
return <FlexWidget style={{ height: 1, width: 'match_parent', backgroundColor: p.border }} />;
}
/**
* The webmail's sender avatar: the contact photo or the sender's logo (on
* white) when the snapshot has one (./avatar-images.ts), else the initials on
* the hashed avatar colour. The initials are drawn underneath, so a picture
* that does not draw still leaves them.
*/
export function Avatar({
initials,
color,
size = 32,
ring,
image,
}: {
initials: string;
color: string;
size?: number;
/** Draws a ring in this colour, for stacked avatars. */
ring?: ColorProp;
image?: { src: string; logo: boolean } | null;
}) {
const face = (
<FlexWidget
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: normalizeHex(color),
justifyContent: 'center',
alignItems: 'center',
...(ring ? { borderWidth: 2, borderColor: ring } : {}),
}}
>
<TextWidget
text={initials}
allowFontScaling={false}
style={{ color: '#ffffff', fontSize: Math.round(size * 0.38), fontWeight: '500' }}
/>
</FlexWidget>
);
if (!image) return face;
// Inside the ring, when there is one.
const inner = ring ? size - 4 : size;
return (
<OverlapWidget style={{ width: size, height: size }}>
{face}
<FlexWidget
style={{
width: size,
height: size,
borderRadius: size / 2,
justifyContent: 'center',
alignItems: 'center',
...(ring ? { backgroundColor: ring } : {}),
}}
>
<FlexWidget style={{ width: inner, height: inner, borderRadius: inner / 2, backgroundColor: image.logo ? '#ffffff' : normalizeHex(color) }}>
<ImageWidget
image={image.src as `data:image${string}`}
imageWidth={inner}
imageHeight={inner}
radius={inner / 2}
resizeMode="cover"
/>
</FlexWidget>
</FlexWidget>
</OverlapWidget>
);
}
/** `rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium` */
export function Pill({
text,
bg,
color,
size = 11,
}: {
text: string;
bg: ColorProp;
color: ColorProp;
size?: number;
}) {
return (
<FlexWidget style={{ backgroundColor: bg, borderRadius: 999, paddingHorizontal: 8, paddingVertical: 2 }}>
<TextWidget text={text} maxLines={1} style={{ color, fontSize: size, fontWeight: '500' }} />
</FlexWidget>
);
}
/** Month/week-view event chip: calendar colour at ~14%, 3dp left edge, text in the colour. */
export function EventChip({
title,
time,
color,
height = 24,
style,
click,
}: {
title: string;
time?: string;
color: string;
height?: number;
style?: FlexWidgetStyle;
click?: WidgetClick;
}) {
const hex = normalizeHex(color);
return (
<FlexWidget
{...click}
style={{
flexDirection: 'row',
alignItems: 'center',
height,
width: 'match_parent',
backgroundColor: chipBackground(hex),
borderLeftWidth: 3,
borderLeftColor: hex,
borderRadius: 3,
paddingLeft: 6,
paddingRight: 4,
...style,
}}
>
{time ? (
<TextWidget text={`${time} `} maxLines={1} style={{ color: `${hex}cc` as ColorProp, fontSize: 12, fontWeight: '500' }} />
) : null}
<TextWidget text={title} maxLines={1} truncate="END" style={{ color: hex, fontSize: 12, fontWeight: '500' }} />
</FlexWidget>
);
}
/** The 4dp colour bar the agenda puts between the time and the title. */
export function ColorBar({ color, height = 36 }: { color: string; height?: number }) {
return <FlexWidget style={{ width: 4, height, borderRadius: 2, backgroundColor: normalizeHex(color) }} />;
}
export function Button({
p,
label,
iconName,
variant = 'outline',
click,
height = 32,
flex,
slop = 0,
}: {
p: WidgetPalette;
label?: string;
iconName?: IconName;
variant?: 'primary' | 'outline';
click?: WidgetClick;
height?: number;
flex?: number;
/**
* Transparent tap area above and below the drawn button, so a 32dp button
* still takes a 48dp tap. Take the same amount off the spacing around it.
*/
slop?: number | { top: number; bottom: number };
}) {
const primary = variant === 'primary';
const [slopTop, slopBottom] = typeof slop === 'number' ? [slop, slop] : [slop.top, slop.bottom];
const fg = primary ? p.primaryFg : p.fg;
const face = (
<FlexWidget
{...(slop ? {} : click)}
style={{
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
height,
...(slop && flex
? { width: 'match_parent' as const }
: flex ? { flex } : { paddingHorizontal: label ? 12 : 0, width: label ? 'wrap_content' : height }),
borderRadius: 6,
backgroundColor: primary ? p.primary : p.bg,
...(primary ? {} : { borderWidth: 1, borderColor: p.input }),
}}
>
{iconName ? <Icon name={iconName} color={fg} size={15} /> : null}
{iconName && label ? <Spacer size={6} horizontal /> : null}
{label ? <TextWidget text={label} maxLines={1} style={{ color: fg, fontSize: 13, fontWeight: '500' }} /> : null}
</FlexWidget>
);
if (!slop) return face;
return (
<FlexWidget
{...click}
style={{
paddingTop: slopTop,
paddingBottom: slopBottom,
...(flex ? { flex } : { width: label ? 'wrap_content' : height }),
}}
>
{face}
</FlexWidget>
);
}
/** Round primary action (compose, new event), the webmail's floating button. */
export function Fab({
p,
iconName,
size = 32,
click,
slop = 0,
}: {
p: WidgetPalette;
iconName: IconName;
size?: number;
click?: WidgetClick;
/** Transparent tap area around the button (see Button). */
slop?: number;
}) {
const face = (
<FlexWidget
{...(slop ? {} : click)}
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: p.primary,
justifyContent: 'center',
alignItems: 'center',
}}
>
<Icon name={iconName} color={p.primaryFg} size={Math.round(size / 2)} />
</FlexWidget>
);
if (!slop) return face;
return (
<FlexWidget {...click} style={{ padding: slop, justifyContent: 'center', alignItems: 'center' }}>
{face}
</FlexWidget>
);
}
/** Icon-only ghost button (search in a header). */
export function GhostIcon({
p,
iconName,
click,
size = 32,
}: {
p: WidgetPalette;
iconName: IconName;
click?: WidgetClick;
size?: number;
}) {
return (
<FlexWidget {...click} style={{ width: size, height: size, justifyContent: 'center', alignItems: 'center' }}>
<Icon name={iconName} color={p.muted} size={18} />
</FlexWidget>
);
}
/** Widget header bar: icon, title, optional count, trailing actions, bottom rule. */
export function Header({
p,
iconName,
iconColor,
title,
count,
trailing,
height = 40,
click,
}: {
p: WidgetPalette;
iconName?: IconName;
iconColor?: string;
title: string;
count?: string;
trailing?: React.ReactNode;
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,
}}
>
{iconName ? <Icon name={iconName} color={iconColor ?? p.fg} size={16} /> : null}
{iconName ? <Spacer size={8} horizontal /> : null}
{/* The title gives way (truncates) before the actions on the right. */}
<FlexWidget style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
<Txt text={title} color={p.fg} size={14} weight="600" />
{count ? <Spacer size={6} horizontal /> : null}
{count ? <Txt text={count} color={p.muted} size={12} /> : null}
</FlexWidget>
{trailing}
</FlexWidget>
<Divider p={p} />
</FlexWidget>
);
}
/** Uppercase section label (`text-xs font-medium uppercase tracking-wider`). */
export function SectionLabel({ p, text }: { p: WidgetPalette; text: string }) {
return (
<TextWidget
text={text.toUpperCase()}
maxLines={1}
style={{ color: p.muted, fontSize: 11, fontWeight: '500', letterSpacing: 0.05, paddingHorizontal: 14, paddingTop: 8, paddingBottom: 4 }}
/>
);
}
/** Centred message for empty, signed-out and error states. */
export function Placeholder({
p,
iconName,
title,
body,
click,
}: {
p: WidgetPalette;
iconName: IconName;
title: string;
body?: string;
click?: WidgetClick;
}) {
return (
<FlexWidget
{...click}
style={{ flex: 1, width: 'match_parent', justifyContent: 'center', alignItems: 'center', padding: 12 }}
>
<Icon name={iconName} color={p.muted} size={22} />
<Spacer size={6} />
<TextWidget text={title} maxLines={2} style={{ color: p.fg, fontSize: 13, fontWeight: '500', textAlign: 'center' }} />
{body ? (
<TextWidget text={body} maxLines={3} style={{ color: p.muted, fontSize: 12, textAlign: 'center', marginTop: 2 }} />
) : null}
</FlexWidget>
);
}
/** Wraps children so an absolutely placed dot/badge can sit on top (FrameLayout). */
export function Overlay({ children, style }: { children?: React.ReactNode; style?: FlexWidgetStyle }) {
return <OverlapWidget style={style}>{children}</OverlapWidget>;
}
+193
View File
@@ -0,0 +1,193 @@
// Turns the snapshot into widget trees and pushes them to the launcher.
import React from 'react';
import { Platform } from 'react-native';
import {
getWidgetInfo,
requestWidgetUpdate,
type WidgetInfo,
type WidgetRepresentation,
} from 'react-native-android-widget';
import catalog from './catalog.json';
import { makeFmt } from './format';
import { loadLocal } from './local-state';
import { Placeholder, Surface } from './primitives';
import { serial } from './serial';
import { readView } from './state';
import type { WidgetSnapshot } from './snapshot';
import { DARK, LIGHT, type WidgetPalette } from './theme';
import type { Layout, WidgetLocalState } from './layouts/types';
import * as mail from './layouts/mail';
import * as moreMail from './layouts/more-mail';
import * as calendar from './layouts/calendar';
import * as plans from './layouts/plans';
import * as hubs from './layouts/hubs';
export const WIDGET_NAMES: string[] = catalog.widgets.map((w) => w.name);
const LAYOUTS: Record<string, Layout> = {
InboxWidget: mail.InboxLayout,
TriageWidget: mail.TriageLayout,
UnreadCountWidget: mail.UnreadCountLayout,
LatestMessageWidget: mail.LatestMessageLayout,
FolderCountsWidget: mail.FolderCountsLayout,
MailShortcutsWidget: mail.MailShortcutsLayout,
AllAccountsWidget: moreMail.AllAccountsLayout,
StarredWidget: moreMail.StarredLayout,
FavouritePeopleWidget: moreMail.FavouritePeopleLayout,
MailSearchWidget: moreMail.MailSearchLayout,
OutboxWidget: moreMail.OutboxLayout,
TagWidget: moreMail.TagLayout,
LastDraftWidget: moreMail.LastDraftLayout,
AgendaWidget: calendar.AgendaLayout,
WeekWidget: calendar.WeekLayout,
MonthWidget: calendar.MonthLayout,
TodayEventsWidget: calendar.TodayEventsLayout,
NextEventWidget: calendar.NextEventLayout,
DateNextWidget: calendar.DateNextLayout,
UpNextWidget: calendar.UpNextLayout,
InvitationsWidget: plans.InvitationsLayout,
FreeTimeWidget: plans.FreeTimeLayout,
TasksWidget: plans.TasksLayout,
TaskProgressWidget: plans.TaskProgressLayout,
CountdownWidget: plans.CountdownLayout,
BirthdaysWidget: plans.BirthdaysLayout,
TodayHubWidget: hubs.TodayHubLayout,
MailAndNextWidget: hubs.MailAndNextLayout,
RecentFilesWidget: hubs.RecentFilesLayout,
AttachmentsWidget: hubs.AttachmentsLayout,
VacationWidget: hubs.VacationLayout,
StorageWidget: hubs.StorageLayout,
};
const STATIC = new Set(catalog.widgets.filter((w) => 'static' in w && w.static).map((w) => w.name));
// Widgets whose data only the running app can load (calendar, tasks, files,
// scheduled sends): until it has, they ask for the app to be opened instead
// of claiming there is nothing to show.
const NEEDS_APP = new Set([
...catalog.widgets.filter((w) => w.board === 'calendar' || w.board === 'plans').map((w) => w.name),
'OutboxWidget',
'TodayHubWidget',
'RecentFilesWidget',
]);
function draw(
name: string,
s: WidgetSnapshot,
p: WidgetPalette,
info: Pick<WidgetInfo, 'width' | 'height' | 'widgetId'>,
local: WidgetLocalState,
now: number,
): React.JSX.Element {
const f = makeFmt(s.locale, s.hour12);
const openApp = { clickAction: 'OPEN_APP' };
const layout = LAYOUTS[name];
if (!layout) {
return <Surface p={p}><Placeholder p={p} iconName="mail" title="Bulwark" click={openApp} /></Surface>;
}
if (!STATIC.has(name)) {
if (!s.signedIn && s.generatedAt > 0) {
return (
<Surface p={p}>
<Placeholder p={p} iconName="lock" title={f.t('widgets.state.signed_out', 'Sign in to Bulwark')} click={openApp} />
</Surface>
);
}
if (s.generatedAt === 0) {
return (
<Surface p={p}>
<Placeholder
p={p}
iconName="refresh"
title={f.t('widgets.state.loading', 'Loading…')}
body={f.t('widgets.state.open_app', 'Open the app once if this stays empty.')}
click={openApp}
/>
</Surface>
);
}
if (NEEDS_APP.has(name) && !s.appDataAt) {
return (
<Surface p={p}>
<Placeholder p={p} iconName="refresh" title={f.t('widgets.state.open_to_load', 'Open Bulwark to load this')} click={openApp} />
</Surface>
);
}
}
try {
return layout({ s, p, f, now, width: info.width, height: info.height, widgetId: info.widgetId, local });
} catch (err) {
console.warn(`[widgets] ${name} failed to draw`, err);
return (
<Surface p={p}>
<Placeholder p={p} iconName="ban" title={f.t('widgets.state.error', "Couldn't show this widget")} click={openApp} />
</Surface>
);
}
}
/**
* Light and dark trees when the app follows the system, one tree otherwise.
* `now` is only fixed for the picker previews.
*/
export function renderFor(
name: string,
s: WidgetSnapshot,
info: Pick<WidgetInfo, 'width' | 'height' | 'widgetId'>,
local: WidgetLocalState,
now = Date.now(),
): WidgetRepresentation {
if (s.theme === 'light') return draw(name, s, LIGHT, info, local, now);
if (s.theme === 'dark') return draw(name, s, DARK, info, local, now);
return { light: draw(name, s, LIGHT, info, local, now), dark: draw(name, s, DARK, info, local, now) };
}
/** Redraw every placed widget of every kind from `snapshot`. */
async function updateAllWidgets(snapshot: WidgetSnapshot): Promise<void> {
await Promise.all(WIDGET_NAMES.map((widgetName) =>
requestWidgetUpdate({
widgetName,
renderWidget: async (info) => renderFor(widgetName, snapshot, info, await loadLocal(info.widgetId)),
}).catch((err) => console.warn(`[widgets] update of ${widgetName} failed`, err)),
));
}
let queued: Promise<void> | null = null;
/**
* Redraw every placed widget from the current data. Draws run one at a time
* in the widgets' lane and each reads the data when it starts, so the last
* 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 (queued) return queued;
const run = serial(async () => {
queued = null;
await updateAllWidgets(await readView());
});
queued = run;
return run;
}
/** One widget's drawing from the current data, for the launcher's own update events. */
export function drawOne(name: string, info: Pick<WidgetInfo, 'width' | 'height' | 'widgetId'>): Promise<WidgetRepresentation> {
return serial(async () => renderFor(name, await readView(), info, await loadLocal(info.widgetId)));
}
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 (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);
placedCache = { at: Date.now(), value };
return value;
}
export function invalidatePlacedCache(): void {
placedCache = null;
}
+206
View File
@@ -0,0 +1,206 @@
// Demo data with something in every section: the same people, mail and
// events as the webmail's demo mode. The widget picker previews
// (src/widgets/previews/*.png) show it at PREVIEW_NOW, so they never contain
// anyone's real mail, and the layout tests draw every widget from it. Times
// are relative to `now`.
import { generateEmailAvatarColor, getEmailInitials } from '../lib/avatar-utils';
import { addDays, startOfDay } from './format';
import { emptySnapshot, type EventItem, type MailItem, type WidgetSnapshot } from './snapshot';
import { normalizeHex } from './theme';
/** The moment the previews show: a Tuesday morning, ahead of the standup. */
export const PREVIEW_NOW = new Date(2026, 8, 22, 9, 35).getTime();
const WORK = '#22c55e';
const PERSONAL = '#3b82f6';
function person(name: string, email: string) {
return {
initials: getEmailInitials(name, email),
color: normalizeHex(generateEmailAvatarColor(name, email)),
};
}
function mail(
id: string,
fromName: string,
fromEmail: string,
subject: string,
preview: string,
receivedAt: number,
extra: Partial<MailItem> = {},
): MailItem {
return {
id,
threadId: `t-${id}`,
accountId: 'demo@example.com@demo',
fromName,
fromEmail,
...person(fromName, fromEmail),
subject,
preview,
receivedAt,
unread: true,
starred: false,
hasAttachment: false,
threadSize: 1,
...extra,
};
}
function event(id: string, title: string, start: number, minutes: number, extra: Partial<EventItem> = {}): EventItem {
return {
id,
serverId: id,
title,
start,
end: start + minutes * 60000,
allDay: false,
color: WORK,
calendarName: 'Work',
participants: [],
...extra,
};
}
export function sampleSnapshot(now: number): WidgetSnapshot {
const today = startOfDay(now);
const at = (days: number, h: number, m = 0) => addDays(today, days) + (h * 60 + m) * 60000;
const ago = (minutes: number) => now - minutes * 60000;
const inbox: MailItem[] = [
mail('m1', 'Sofia Russo', 'sofia.russo@example.com', 'when are you coming home?',
'Hi sweetie, your father and I were just talking - we miss you.', ago(25), { threadSize: 2 }),
mail('m2', 'Sarah Kim', 'sarah.kim@example.com', 'Invoice #2024-089 & landing-page prototype v3',
'Hi, please find attached the invoice for October and a screenshot', ago(46), { hasAttachment: true }),
mail('m3', 'Alice Johnson', 'alice.johnson@example.com', 'Re: Q4 Project Timeline',
'Works for me. Bob, can you confirm the freeze date?', ago(79), { threadSize: 3 }),
mail('m4', 'Marcus Hughes', 'marcus.hughes@example.com', 'book club thursday - picking the next one',
'Three candidates so far, vote by Wednesday', ago(97)),
mail('m5', "Dr. Smith's Office", 'appointments@drsmith.example', 'Appointment reminder - Tuesday at 10:00',
'This is a friendly reminder of your upcoming cleaning appointment', ago(60 * 26), { unread: false, starred: true }),
mail('m6', 'Carlos Rivera', 'carlos.rivera@example.com', 'Friday dinner - moved to 7:30 (sorry!)',
'Same place, just a bit later', ago(60 * 50), { unread: false, starred: true }),
];
const standup = event('e1', 'Weekly Standup', at(0, 10), 30, {
videoUrl: 'https://zoom.example/j/123456',
videoName: 'Zoom',
participants: [
{ name: 'Alice Johnson', email: 'alice.johnson@example.com', ...person('Alice Johnson', 'alice.johnson@example.com') },
{ name: 'Bob Chen', email: 'bob.chen@example.com', ...person('Bob Chen', 'bob.chen@example.com') },
],
});
const events: EventItem[] = [
standup,
event('e2', 'Lunch Meeting with Sarah', at(0, 12, 30), 60, {
location: 'The Garden Bistro',
}),
event('e3', 'Dentist Appointment', at(1, 10), 60, {
color: PERSONAL, calendarName: 'Personal', location: 'Dr. Smith Dental Clinic',
}),
event('e4', 'Quarterly Review', at(2, 14), 120),
event('e5', 'Birthday Party', at(3, 0), 24 * 60, { allDay: true, color: PERSONAL, calendarName: 'Personal' }),
event('e6', 'Weekend Trip', at(4, 0), 2 * 24 * 60, { allDay: true, color: PERSONAL, calendarName: 'Personal' }),
event('e7', 'Design review', at(-1, 15), 60),
event('e8', 'Sprint planning', at(-1, 11), 90),
event('e9', '1:1 with Alice', at(1, 15, 30), 30),
event('e10', 'Team retro', at(3, 11), 60),
];
const base = emptySnapshot();
return {
...base,
generatedAt: now,
appDataAt: now,
signedIn: true,
theme: 'light',
locale: 'en',
hour12: false,
weekStart: 1,
accounts: [
{ id: 'demo@example.com@demo', label: 'Work', color: '#2563eb', unread: 6 },
{ id: 'demo@home.example@demo', label: 'Personal', color: '#db2777', unread: 4 },
],
activeAccountId: 'demo@example.com@demo',
mail: {
...base.mail,
folders: [
{ role: 'inbox', name: 'Inbox', unread: 10, total: 23 },
{ role: 'drafts', name: 'Drafts', unread: 0, total: 2 },
{ role: 'junk', name: 'Spam', unread: 3, total: 3 },
{ role: 'archive', name: 'Archive', unread: 0, total: 120 },
],
inbox,
unified: [
inbox[0],
{ ...inbox[1], accountId: 'demo@home.example@demo' },
inbox[2],
inbox[3],
],
starred: inbox.filter((m) => m.starred),
starredCount: 3,
drafts: [mail('d1', 'Demo User', 'demo@example.com', 'Meeting Notes - Draft', 'Here are the notes from today', ago(20), { unread: false })],
draftCount: 2,
scheduled: [
{ id: 's1', to: 'Alice Johnson', subject: 'Updated Requirements Document', sendAt: at(1, 8) },
{ id: 's2', to: 'Sarah Kim', subject: 'Re: Invoice #2024-089', sendAt: at(4, 17) },
],
pendingChanges: 0,
favourites: [
{ name: 'Sofia Russo', email: 'sofia.russo@example.com', ...person('Sofia Russo', 'sofia.russo@example.com'), unread: 2, lastAt: ago(25) },
{ name: 'Alice Johnson', email: 'alice.johnson@example.com', ...person('Alice Johnson', 'alice.johnson@example.com'), unread: 1, lastAt: ago(79) },
{ name: 'Sarah Kim', email: 'sarah.kim@example.com', ...person('Sarah Kim', 'sarah.kim@example.com'), unread: 0, lastAt: ago(60 * 30) },
{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com', ...person('Carlos Rivera', 'carlos.rivera@example.com'), unread: 0, lastAt: ago(60 * 50) },
],
recentSearches: ['from:sarah', 'invoice', 'has:attachment', 'Q4 timeline', 'is:unread'],
tags: [{ keyword: '$label:receipts', name: 'Receipts', color: '#f97316', unread: 2, total: 14, latestFrom: 'Sarah Kim', latestAt: ago(46) }],
attachments: [
{ emailId: 'm2', threadId: 't-m2', accountId: 'demo@example.com@demo', fromName: 'Sarah Kim', receivedAt: ago(46),
files: [{ name: 'Invoice-2024-089.pdf', type: 'application/pdf' }, { name: 'prototype-v3.png', type: 'image/png' }] },
{ emailId: 'm7', threadId: 't-m7', accountId: 'demo@example.com@demo', fromName: 'Anna Kowalski', receivedAt: ago(60 * 48),
files: [{ name: 'wedding-001.jpg', type: 'image/jpeg' }, { name: 'wedding-014.jpg', type: 'image/jpeg' }, { name: 'wedding-038.jpg', type: 'image/jpeg' }] },
],
},
calendar: {
supported: true,
events,
invitations: [{
...event('e4', 'Quarterly Review', at(2, 14), 120, {
organizerName: 'Alice Johnson',
participants: [
{ name: 'Alice Johnson', email: 'alice.johnson@example.com', ...person('Alice Johnson', 'alice.johnson@example.com') },
{ name: 'Bob Chen', email: 'bob.chen@example.com', ...person('Bob Chen', 'bob.chen@example.com') },
],
myStatus: 'needs-action',
}),
participantId: 'me',
}],
birthdays: [
{ name: 'Alice Johnson', email: 'alice.johnson@example.com', ...person('Alice Johnson', 'alice.johnson@example.com'), date: addDays(today, 10) },
{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com', ...person('Carlos Rivera', 'carlos.rivera@example.com'), date: addDays(today, 24) },
],
},
tasks: {
supported: true,
items: [
{ id: 'k1', serverId: 'k1', title: 'Pay electricity bill', due: addDays(today, -2), dueHasTime: false, done: false, calendarName: 'Personal', color: PERSONAL },
{ id: 'k2', serverId: 'k2', title: 'Buy groceries', due: today, dueHasTime: false, done: false, calendarName: 'Personal', color: PERSONAL },
{ id: 'k3', serverId: 'k3', title: 'Prepare quarterly report', due: addDays(today, 1), dueHasTime: false, done: false, calendarName: 'Work', color: WORK },
{ id: 'k4', serverId: 'k4', title: 'Schedule dentist appointment', due: addDays(today, 3), dueHasTime: false, done: false, calendarName: 'Personal', color: PERSONAL },
{ id: 'k5', serverId: 'k5', title: 'Review pull requests', due: today, dueHasTime: false, done: true, calendarName: 'Work', color: WORK },
],
},
files: {
supported: true,
items: [
{ id: 'f1', name: 'budget.xlsx', isFolder: false, size: 67 * 1024, modified: ago(60 * 24), type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
{ id: 'f2', name: 'Documents', isFolder: true, size: 0, modified: ago(60 * 48), type: '' },
{ id: 'f3', name: 'product-launch.pptx', isFolder: false, size: 500 * 1024, modified: ago(60 * 72), type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
],
},
vacation: { enabled: true, to: addDays(today, 7), subject: 'Out of Office' },
quota: { used: 1.15 * 1024 ** 3, limit: 5 * 1024 ** 3 },
};
}
+16
View File
@@ -0,0 +1,16 @@
// One lane for everything that reads and writes the stored widget data or
// redraws the widgets. Each widget event (every tap, every periodic update)
// runs as its own headless task in the same JS runtime, and they interleave
// at every await; without this, two taps load the same snapshot and the
// second save drops the first change, and a redraw of an older snapshot can
// land after a newer one, leaving buttons that act on a message the widget
// no longer shows.
let tail: Promise<unknown> = Promise.resolve();
/** Run `fn` after everything queued before it has finished. */
export function serial<T>(fn: () => Promise<T>): Promise<T> {
const run = tail.then(fn, fn);
tail = run.catch(() => undefined);
return run;
}
+291
View File
@@ -0,0 +1,291 @@
// The data every home-screen widget renders from. The app (or the headless
// widget task) fetches it from the server, stores it in AsyncStorage and asks
// the launcher to redraw; layouts only ever read this object, so a widget can
// draw without the network and without the React tree.
//
// Times are epoch milliseconds. Views that depend on "now" (what's next,
// what's overdue, free time) are derived at render time in ./derive.ts so a
// snapshot stays correct as the day moves on.
import AsyncStorage from '@react-native-async-storage/async-storage';
export const SNAPSHOT_KEY = 'widgets:snapshot:v1';
export interface MailItem {
id: string;
threadId: string;
/** Signed-in account (app registry id) the message belongs to. */
accountId: string;
/** JMAP account owning the message when it is not the user's own. */
jmapAccountId?: string;
fromName: string;
fromEmail: string;
initials: string;
/** Avatar colour, `#rrggbb`. */
color: string;
subject: string;
preview: string;
receivedAt: number;
unread: boolean;
starred: boolean;
hasAttachment: boolean;
/** Messages in the thread; 1 when unknown. */
threadSize: number;
}
export interface FolderCount {
role: 'inbox' | 'drafts' | 'sent' | 'archive' | 'junk' | 'trash' | 'scheduled';
name: string;
unread: number;
total: number;
}
export interface AccountSummary {
id: string;
label: string;
color: string;
unread: number;
}
export interface ScheduledItem {
id: string;
to: string;
subject: string;
sendAt: number;
}
export interface Person {
name: string;
email: string;
initials: string;
color: string;
unread: number;
lastAt: number;
}
export interface TagCount {
keyword: string;
name: string;
color: string;
unread: number;
total: number;
latestFrom?: string;
latestAt?: number;
}
export interface AttachmentGroup {
emailId: string;
/** Absent in snapshots stored before it was kept. */
threadId?: string;
accountId: string;
fromName: string;
receivedAt: number;
files: Array<{ name: string; type: string }>;
}
export interface EventParticipant {
name: string;
email: string;
initials: string;
color: string;
}
export interface EventItem {
/** Store id (occurrence-specific for a recurring series). */
id: string;
/** Server id of the stored event, for deep links. */
serverId: string;
/** Signed-in account (app registry id) the event was loaded for. */
accountId?: string;
jmapAccountId?: string;
title: string;
start: number;
end: number;
allDay: boolean;
color: string;
calendarName: string;
location?: string;
videoUrl?: string;
videoName?: string;
participants: EventParticipant[];
/** The user's own participation status when they are invited. */
myStatus?: 'needs-action' | 'accepted' | 'tentative' | 'declined';
organizerName?: string;
}
export interface Invitation extends EventItem {
/** Participant id of the user in the event, for the RSVP call. */
participantId: string;
}
export interface Birthday {
name: string;
email?: string;
initials: string;
color: string;
/** Next occurrence, local midnight. */
date: number;
}
export interface TaskItem {
id: string;
/** Raw JMAP id, for CalendarEvent/set. */
serverId: string;
/** Signed-in account (app registry id) the task was loaded for. */
accountId?: string;
jmapAccountId?: string;
title: string;
due?: number;
/** false for date-only due dates. */
dueHasTime: boolean;
done: boolean;
calendarName: string;
color: string;
}
export interface FileItem {
id: string;
name: string;
isFolder: boolean;
size: number;
modified: number;
type: string;
}
export interface VacationState {
enabled: boolean;
from?: number;
to?: number;
subject?: string;
}
export interface QuotaState {
used: number;
limit: number;
}
/** Avatar pictures (./avatar-images.ts): contact photos by address, sender logos by domain, as data URIs. */
export interface AvatarImages {
photos: Record<string, string>;
favicons: Record<string, string>;
}
/** Told on the widget when a button's change did not reach the server. */
export interface ActionNotice {
action: 'archive' | 'trash' | 'markRead' | 'rsvp' | 'toggleTask';
/** Subject, event or task title the change was about. */
label: string;
at: number;
/** The tap that failed, repeated when the notice is tapped. */
retry: Record<string, unknown>;
}
export interface WidgetSnapshot {
version: 1;
generatedAt: number;
/**
* When calendar, tasks, files and scheduled mail were last loaded. Those
* need the running app (see build.ts); 0 until it has run once.
*/
appDataAt: number;
/** False when no account is signed in; widgets then ask the user to sign in. */
signedIn: boolean;
/** The app's theme preference; 'system' lets each widget follow the launcher. */
theme: 'light' | 'dark' | 'system';
/** Resolved app language (for date names). */
locale: string;
hour12: boolean;
/** First day of the week, 0 = Sunday, 1 = Monday. */
weekStart: 0 | 1;
accounts: AccountSummary[];
/** The signed-in account the single-account widgets show. */
activeAccountId: string | null;
mail: {
folders: FolderCount[];
inbox: MailItem[];
unified: MailItem[];
starred: MailItem[];
starredCount: number;
drafts: MailItem[];
draftCount: number;
scheduled: ScheduledItem[];
/** Changes made offline that have not reached the server yet. */
pendingChanges: number;
favourites: Person[];
recentSearches: string[];
tags: TagCount[];
attachments: AttachmentGroup[];
};
calendar: {
supported: boolean;
/** Events from the start of the current month through five weeks ahead. */
events: EventItem[];
invitations: Invitation[];
birthdays: Birthday[];
};
tasks: { supported: boolean; items: TaskItem[] };
files: { supported: boolean; items: FileItem[] };
vacation: VacationState | null;
quota: QuotaState | null;
/** Absent in snapshots stored before avatars had pictures. */
images?: AvatarImages;
/**
* Only in what the widgets draw, never stored: the last button change the
* server did not take (./state.ts).
*/
notice?: ActionNotice | null;
}
export function emptySnapshot(): WidgetSnapshot {
return {
version: 1,
generatedAt: 0,
appDataAt: 0,
signedIn: false,
theme: 'system',
locale: 'en',
hour12: false,
weekStart: 1,
accounts: [],
activeAccountId: null,
mail: {
folders: [],
inbox: [],
unified: [],
starred: [],
starredCount: 0,
drafts: [],
draftCount: 0,
scheduled: [],
pendingChanges: 0,
favourites: [],
recentSearches: [],
tags: [],
attachments: [],
},
calendar: { supported: false, events: [], invitations: [], birthdays: [] },
tasks: { supported: false, items: [] },
files: { supported: false, items: [] },
vacation: null,
quota: null,
};
}
export async function loadSnapshot(): Promise<WidgetSnapshot | null> {
try {
const raw = await AsyncStorage.getItem(SNAPSHOT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as WidgetSnapshot;
return parsed?.version === 1 ? parsed : null;
} catch {
return null;
}
}
export async function saveSnapshot(snapshot: WidgetSnapshot): Promise<void> {
await AsyncStorage.setItem(SNAPSHOT_KEY, JSON.stringify(snapshot));
}
export async function clearSnapshot(): Promise<void> {
await AsyncStorage.removeItem(SNAPSHOT_KEY);
}
+77
View File
@@ -0,0 +1,77 @@
// The widgets' stored data and the edits widget buttons lay over it, read and
// written in one lane (./serial.ts) so headless tasks running side by side
// neither lose each other's changes nor draw an older state over a newer one.
import { serial } from './serial';
import {
applyPending,
confirmedBy,
expire,
loadPending,
savePending,
type PendingChange,
} from './pending';
import { emptySnapshot, loadSnapshot, saveSnapshot, type ActionNotice, type WidgetSnapshot } from './snapshot';
/** The view without queueing; only for code already running in the lane. */
export async function readView(now = Date.now()): Promise<WidgetSnapshot> {
const [stored, raw] = await Promise.all([loadSnapshot(), loadPending()]);
const pending = expire(raw, now);
return { ...applyPending(stored ?? emptySnapshot(), pending.ops), notice: pending.notice ?? null };
}
/** What the widgets draw: the stored snapshot with the buttons' changes laid over it. */
export function currentView(now = Date.now()): Promise<WidgetSnapshot> {
return serial(() => readView(now));
}
/**
* Store a refresh that started reading the server at `startedAt`. Changes the
* server took before that are now in the data and stop being laid over it;
* later ones and those still in flight stay.
*/
export function storeRefresh(next: WidgetSnapshot, startedAt: number): Promise<void> {
return serial(async () => {
await saveSnapshot(next);
const pending = expire(await loadPending(), Date.now());
const appData = next.appDataAt >= startedAt;
await savePending({ ...pending, ops: confirmedBy(pending.ops, startedAt, appData) });
});
}
/** Replace the data outright (signed out): no change of the old data may linger. */
export function replaceAll(next: WidgetSnapshot): Promise<void> {
return serial(async () => {
await saveSnapshot(next);
await savePending({ ops: [], notice: null });
});
}
let counter = 0;
/** Lay a button's change over the data until the server has answered. Returns its key. */
export function track(change: PendingChange, now = Date.now()): Promise<string> {
const key = `${now}-${++counter}`;
return serial(async () => {
const pending = expire(await loadPending(), now);
// A new tap (a retry included) answers whatever the notice was about.
await savePending({ ops: [...pending.ops, { key, change, at: now }], notice: null });
return key;
});
}
/**
* The server answered. A change it took stays laid over the data until a
* refresh has caught up with it; one it refused (or that never reached it) is
* dropped, which shows the data as the server last described it, and
* `notice` tells the user.
*/
export function settle(key: string, ok: boolean, notice?: ActionNotice, now = Date.now()): Promise<void> {
return serial(async () => {
const pending = expire(await loadPending(), now);
const ops = ok
? pending.ops.map((o) => (o.key === key ? { ...o, doneAt: now } : o))
: pending.ops.filter((o) => o.key !== key);
await savePending({ ops, notice: !ok && notice ? notice : pending.notice ?? null });
});
}
+117
View File
@@ -0,0 +1,117 @@
// Keeps the widgets current while the app runs. The launcher only asks for an
// update every 30 minutes and pushes only announce new mail, so the app
// refreshes the snapshot itself whenever what the widgets show may have
// changed: mail or calendar data, the theme or clock settings, and when the
// app goes to the background (the most likely moment to glance at a widget).
import { AppState, NativeModules, Platform } from 'react-native';
import { readRegistry, refreshSnapshot } from './build';
import { hasPlacedWidgets, redrawAll } from './render';
import { emptySnapshot } from './snapshot';
import { replaceAll } from './state';
/** Refreshes are spaced at least this far apart unless the app is leaving. */
const MIN_INTERVAL_MS = 45_000;
let timer: ReturnType<typeof setTimeout> | null = null;
let lastRun = 0;
async function run(): Promise<void> {
timer = null;
lastRun = Date.now();
try {
if (!(await hasPlacedWidgets())) return;
// Something changed since a refresh already running started; join the next one.
await refreshSnapshot({ after: 'change' });
await redrawAll();
} catch (err) {
console.warn('[widgets] sync failed', err);
}
}
function schedule(delayMs: number, urgent = false): void {
const earliest = urgent ? 0 : Math.max(0, lastRun + MIN_INTERVAL_MS - Date.now());
const wait = Math.max(delayMs, earliest);
if (timer) clearTimeout(timer);
timer = setTimeout(() => { void run(); }, wait);
}
/**
* Leaving the app is the most likely moment to glance at a widget, so the
* widgets refresh right then, in a headless task of their own
* (BulwarkWidgetRefreshService). React Native pauses JS timers while the app
* is in the background, and fetch hands every response over through a timer,
* so a refresh run by the backgrounded app itself stops at its first request;
* a running headless task keeps timers going.
*/
export function handleAppState(next: string): void {
if (next !== 'background') return;
if (timer) clearTimeout(timer);
timer = null;
const native = NativeModules.BulwarkWidgets as { refreshInBackground?: () => Promise<boolean> } | undefined;
void (native?.refreshInBackground?.() ?? Promise.resolve(false))
.catch(() => false)
.then((started) => {
if (!started) void run();
});
}
/** Start following app state; returns the cleanup. No-op off Android. */
export function startWidgetSync(): () => void {
if (Platform.OS !== 'android') 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');
schedule(3000, true);
const unsubscribers = [
useEmailStore.subscribe((state, prev) => {
if (state.mailboxes !== prev.mailboxes || state.emails !== prev.emails) schedule(5000);
}),
useCalendarStore.subscribe((state, prev) => {
if (state.events !== prev.events || state.tasks !== prev.tasks || state.calendars !== prev.calendars) schedule(8000);
}),
useSettingsStore.subscribe((state, prev) => {
if (
state.theme !== prev.theme
|| state.timeFormat !== prev.timeFormat
|| state.calendarFirstDayOfWeek !== prev.calendarFirstDayOfWeek
|| state.enableCalendarTasks !== prev.enableCalendarTasks
) {
schedule(500, true);
}
}),
];
const appState = AppState.addEventListener('change', handleAppState);
return () => {
for (const unsubscribe of unsubscribers) unsubscribe();
appState.remove();
if (timer) clearTimeout(timer);
timer = null;
};
}
/** Wipe what the widgets show after the last account signed out. */
export async function signOutWidgets(): Promise<void> {
if (Platform.OS !== 'android') 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() });
await redrawAll();
}
/**
* Called from the push headless task: new mail arrived while the app was in
* the background. Skipped entirely when no widget is placed.
*/
export async function refreshWidgetsInBackground(): Promise<void> {
if (Platform.OS !== 'android') return;
try {
if (!(await hasPlacedWidgets(0))) return;
// Something changed since a refresh already running started; join the next one.
await refreshSnapshot({ after: 'change' });
await redrawAll();
} catch (err) {
console.warn('[widgets] background refresh failed', err);
}
}
+48
View File
@@ -0,0 +1,48 @@
// Entry point of the headless task react-native-android-widget starts for
// every widget event (added, periodic update, resize, removed, tap). It can
// run in a cold JS runtime with no UI, so it only touches the stored snapshot
// and the widgets' own JMAP code.
import type { WidgetTaskHandlerProps } from 'react-native-android-widget';
import { handleWidgetAction } from './actions';
import { refreshSnapshot } from './build';
import { removeLocal } from './local-state';
import { drawOne, invalidatePlacedCache, redrawAll } from './render';
import { loadSnapshot } from './snapshot';
/** A snapshot younger than this is drawn as it is; older ones trigger a fetch. */
const FRESH_MS = 5 * 60 * 1000;
export async function widgetTaskHandler(props: WidgetTaskHandlerProps): Promise<void> {
const { widgetInfo, widgetAction, clickAction, clickActionData, renderWidget } = props;
const name = widgetInfo.widgetName;
switch (widgetAction) {
case 'WIDGET_ADDED':
case 'WIDGET_UPDATE':
case 'WIDGET_RESIZED': {
if (widgetAction === 'WIDGET_ADDED') invalidatePlacedCache();
renderWidget(await drawOne(name, widgetInfo));
if (widgetAction === 'WIDGET_RESIZED') return;
const stored = await loadSnapshot();
if (stored && Date.now() - stored.generatedAt < FRESH_MS) return;
// Every placed widget gets its own periodic update; they share one
// refresh (see refreshSnapshot) and the redraws that follow collapse.
await refreshSnapshot();
await redrawAll();
return;
}
case 'WIDGET_DELETED':
invalidatePlacedCache();
await removeLocal(widgetInfo.widgetId);
return;
case 'WIDGET_CLICK':
if (clickAction) await handleWidgetAction(clickAction, clickActionData ?? {}, widgetInfo.widgetId);
return;
default:
return;
}
}
+176
View File
@@ -0,0 +1,176 @@
// Home-screen widget palettes. These copy the webmail's standard theme as it
// renders (app/globals.css plus the Tailwind classes the list, calendar and
// sidebar use), not the in-app ThemePalette: in the webmail's dark mode the
// primary colour is white, while the native dark palette keeps a blue primary.
// Widgets are meant to look like the webmail, so they carry their own tokens.
import type { ColorProp } from 'react-native-android-widget';
export type WidgetScheme = 'light' | 'dark';
export interface WidgetPalette {
scheme: WidgetScheme;
/**
* Widget surface (webmail `--color-background`). The edge around it is drawn
* natively, as `widget_frame` in android/app/src/main/res/values/colors.xml.
*/
bg: ColorProp;
fg: ColorProp;
muted: ColorProp;
/** Search pill, segmented track, quota ring track (webmail `--color-muted`). */
mutedBg: ColorProp;
border: ColorProp;
primary: ColorProp;
primaryFg: ColorProp;
accent: ColorProp;
accentFg: ColorProp;
/** `bg-accent/30`, the tint of an unread message row. */
unreadRow: ColorProp;
unreadDot: ColorProp;
/** Agenda day-header band. */
dayHeader: ColorProp;
/** "Today" label in the agenda. */
today: ColorProp;
/** Read subject (`text-foreground/90`). */
subjectRead: ColorProp;
success: ColorProp;
successText: ColorProp;
successBg: ColorProp;
info: ColorProp;
infoBg: ColorProp;
destructive: ColorProp;
destructiveBg: ColorProp;
overdue: ColorProp;
dueToday: ColorProp;
star: ColorProp;
/** Outline button edge (webmail `--color-input`). */
input: ColorProp;
/** Scheduled-send label (`sky-500/10` pill with `sky-700/300` text). */
scheduledBg: ColorProp;
scheduledText: ColorProp;
warningBg: ColorProp;
warningText: ColorProp;
folder: {
inbox: ColorProp;
drafts: ColorProp;
sent: ColorProp;
scheduled: ColorProp;
archive: ColorProp;
junk: ColorProp;
};
}
export const LIGHT: WidgetPalette = {
scheme: 'light',
bg: '#ffffff',
fg: '#0f172a',
muted: '#64748b',
mutedBg: '#f1f5f9',
border: '#e2e8f0',
primary: '#3b82f6',
primaryFg: '#ffffff',
accent: '#dbeafe',
accentFg: '#1e40af',
unreadRow: '#f4f9ff',
unreadDot: '#3b82f6',
dayHeader: '#f4f7fa',
today: '#3b82f6',
subjectRead: '#283548',
success: '#22c55e',
successText: '#15803d',
successBg: 'rgba(34, 197, 94, 0.15)',
info: '#2563eb',
infoBg: 'rgba(59, 130, 246, 0.15)',
destructive: '#ef4444',
destructiveBg: 'rgba(239, 68, 68, 0.15)',
overdue: '#e7000b',
dueToday: '#155dfc',
star: '#fbbf24',
input: '#e2e8f0',
scheduledBg: 'rgba(0, 166, 244, 0.1)',
scheduledText: '#0069a8',
warningBg: 'rgba(254, 154, 0, 0.12)',
warningText: '#973c00',
folder: {
inbox: '#155dfccc',
drafts: '#7f22fecc',
sent: '#009966cc',
scheduled: '#0084d1',
archive: '#e17100cc',
junk: '#e7000bcc',
},
};
export const DARK: WidgetPalette = {
scheme: 'dark',
bg: '#0a0a0a',
fg: '#fafafa',
muted: '#a3a3a3',
mutedBg: '#262626',
border: '#2d2d2d',
primary: '#fafafa',
primaryFg: '#171717',
accent: '#1e3a8a',
accentFg: '#dbeafe',
unreadRow: '#0f1830',
unreadDot: '#60a5fa',
dayHeader: '#202020',
today: '#fafafa',
subjectRead: '#e1e1e1',
success: '#16a34a',
successText: '#22c55e',
successBg: 'rgba(22, 163, 74, 0.15)',
info: '#60a5fa',
infoBg: 'rgba(96, 165, 250, 0.15)',
destructive: '#ef4444',
destructiveBg: 'rgba(239, 68, 68, 0.15)',
overdue: '#ff6467',
dueToday: '#51a2ff',
star: '#fbbf24',
input: '#262626',
scheduledBg: 'rgba(0, 166, 244, 0.1)',
scheduledText: '#74d4ff',
warningBg: 'rgba(254, 154, 0, 0.12)',
warningText: '#ffd230',
folder: {
inbox: '#51a2ffcc',
drafts: '#a684ffcc',
sent: '#00d492cc',
scheduled: '#00bcff',
archive: '#ffb900cc',
junk: '#ff6467cc',
},
};
/** Tint a calendar colour the way event chips do (`${color}24`, ~14% alpha). */
export function chipBackground(color: string): ColorProp {
const hex = normalizeHex(color);
return `${hex}24` as ColorProp;
}
/** Accepts `#rgb`, `#rrggbb`, `hsl(h, s%, l%)`; anything else falls back to blue. */
export function normalizeHex(color: string | null | undefined): `#${string}` {
if (!color) return '#3b82f6';
const c = color.trim();
if (/^#[0-9a-f]{6}$/i.test(c)) return c.toLowerCase() as `#${string}`;
if (/^#[0-9a-f]{3}$/i.test(c)) {
const [r, g, b] = c.slice(1).split('');
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase() as `#${string}`;
}
if (/^#[0-9a-f]{8}$/i.test(c)) return c.slice(0, 7).toLowerCase() as `#${string}`;
const hsl = /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%/i.exec(c);
if (hsl) return hslToHex(Number(hsl[1]), Number(hsl[2]), Number(hsl[3]));
return '#3b82f6';
}
export function hslToHex(h: number, s: number, l: number): `#${string}` {
const sat = s / 100;
const light = l / 100;
const k = (n: number) => (n + h / 30) % 12;
const a = sat * Math.min(light, 1 - light);
const f = (n: number) => light - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const hex = [f(0), f(8), f(4)]
.map((x) => Math.round(x * 255).toString(16).padStart(2, '0'))
.join('');
return `#${hex}`;
}
+15
View File
@@ -0,0 +1,15 @@
// Whether the app's UI has started in this JS runtime. Widget and push tasks
// can start the runtime with no UI; until the UI starts, nothing else uses
// the app's `jmapClient` singleton, so a widget refresh may sign it in to
// the active account and load calendar and tasks with the app's own code.
let started = false;
/** Called when the UI renders for the first time (index.ts). */
export function markUiStarted(): void {
started = true;
}
export function uiStarted(): boolean {
return started;
}