Merge branch 'main' into feat/stalwart-1.0
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
agendaDays,
|
||||
busyBlocks,
|
||||
countdownTarget,
|
||||
dueKind,
|
||||
eventsOnDay,
|
||||
freeTime,
|
||||
monthGrid,
|
||||
nextEvent,
|
||||
sortTasks,
|
||||
startOfWeek,
|
||||
} from '../derive';
|
||||
import type { EventItem, TaskItem } from '../snapshot';
|
||||
|
||||
// Monday 28 September 2026, 09:05 local time.
|
||||
const NOW = new Date(2026, 8, 28, 9, 5).getTime();
|
||||
const at = (day: number, h: number, m = 0) => new Date(2026, 8, day, h, m).getTime();
|
||||
|
||||
function ev(id: string, start: number, end: number, extra: Partial<EventItem> = {}): EventItem {
|
||||
return {
|
||||
id,
|
||||
serverId: id,
|
||||
title: id,
|
||||
start,
|
||||
end,
|
||||
allDay: false,
|
||||
color: '#22c55e',
|
||||
calendarName: 'Work',
|
||||
participants: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const standup = ev('standup', at(28, 9, 30), at(28, 10));
|
||||
const dentist = ev('dentist', at(29, 10), at(29, 11), { color: '#3b82f6' });
|
||||
const lunch = ev('lunch', at(30, 12, 30), at(30, 13, 30));
|
||||
const party = ev('party', new Date(2026, 9, 1).getTime(), new Date(2026, 9, 2).getTime(), { allDay: true, color: '#3b82f6' });
|
||||
const review = ev('review', new Date(2026, 9, 1, 14).getTime(), new Date(2026, 9, 1, 16).getTime());
|
||||
const trip = ev('trip', new Date(2026, 9, 4).getTime(), new Date(2026, 9, 5).getTime(), { allDay: true, color: '#3b82f6' });
|
||||
const earlier = ev('earlier', at(28, 7), at(28, 8));
|
||||
const EVENTS = [earlier, standup, dentist, lunch, party, review, trip];
|
||||
|
||||
describe('calendar views', () => {
|
||||
it('finds the next timed event and whether it is running', () => {
|
||||
expect(nextEvent(EVENTS, NOW)?.event.id).toBe('standup');
|
||||
expect(nextEvent(EVENTS, NOW)?.running).toBe(false);
|
||||
expect(nextEvent(EVENTS, at(28, 9, 45))?.running).toBe(true);
|
||||
});
|
||||
|
||||
it('lists a day with all-day events first', () => {
|
||||
const thursday = new Date(2026, 9, 1).getTime();
|
||||
expect(eventsOnDay(EVENTS, thursday).map((e) => e.id)).toEqual(['party', 'review']);
|
||||
});
|
||||
|
||||
it('drops finished events from today in the agenda', () => {
|
||||
const days = agendaDays(EVENTS, NOW, 3);
|
||||
expect(days.map((d) => d.events.map((e) => e.id))).toEqual([['standup'], ['dentist'], ['lunch']]);
|
||||
});
|
||||
|
||||
it('starts the week on the configured day', () => {
|
||||
expect(new Date(startOfWeek(NOW, 1)).getDate()).toBe(28);
|
||||
expect(new Date(startOfWeek(NOW, 0)).getDate()).toBe(27);
|
||||
});
|
||||
|
||||
it('builds the month as whole weeks with dots and today', () => {
|
||||
const weeks = monthGrid(EVENTS, NOW, 1);
|
||||
expect(weeks).toHaveLength(5);
|
||||
expect(weeks[0][0].day).toBe(31);
|
||||
expect(weeks[0][0].inMonth).toBe(false);
|
||||
const today = weeks.flat().find((c) => c.isToday);
|
||||
expect(today?.day).toBe(28);
|
||||
expect(today?.dots).toEqual(['#22c55e']);
|
||||
});
|
||||
|
||||
it('places busy blocks inside working hours', () => {
|
||||
const [block] = busyBlocks(EVENTS, new Date(2026, 8, 29).getTime());
|
||||
expect(block.from).toBeCloseTo(0.2);
|
||||
expect(block.to).toBeCloseTo(0.3);
|
||||
});
|
||||
|
||||
it('works out free time and the longest gap left today', () => {
|
||||
const free = freeTime(EVENTS, NOW);
|
||||
expect(free.busyNow).toBe(false);
|
||||
expect(free.next?.id).toBe('standup');
|
||||
expect(free.longestGap).toEqual({ from: at(28, 10), to: at(28, 18) });
|
||||
});
|
||||
|
||||
it('counts down to the next all-day or multi-day event', () => {
|
||||
expect(countdownTarget(EVENTS, NOW)?.id).toBe('party');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tasks', () => {
|
||||
const task = (id: string, due: number | undefined, done = false, dueHasTime = false): TaskItem => ({
|
||||
id, serverId: id, title: id, due, dueHasTime, done, calendarName: 'Personal', color: '#3b82f6',
|
||||
});
|
||||
|
||||
it('classifies due dates', () => {
|
||||
expect(dueKind(task('a', at(26, 0)), NOW)).toBe('overdue');
|
||||
expect(dueKind(task('b', at(28, 0)), NOW)).toBe('today');
|
||||
expect(dueKind(task('c', at(28, 8), false, true), NOW)).toBe('overdue');
|
||||
expect(dueKind(task('d', at(29, 0)), NOW)).toBe('tomorrow');
|
||||
expect(dueKind(task('e', undefined), NOW)).toBe('none');
|
||||
});
|
||||
|
||||
it('puts overdue first and done last', () => {
|
||||
const sorted = sortTasks([
|
||||
task('done', at(20, 0), true),
|
||||
task('later', new Date(2026, 9, 3).getTime()),
|
||||
task('none', undefined),
|
||||
task('today', at(28, 0)),
|
||||
task('overdue', at(26, 0)),
|
||||
], NOW);
|
||||
expect(sorted.map((t) => t.id)).toEqual(['overdue', 'today', 'later', 'none', 'done']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Email, Mailbox } from '../../api/types';
|
||||
import { folderCounts, rankPeople, toMailItem } from '../jmap';
|
||||
|
||||
function email(id: string, from: string, name: string, seen: boolean, receivedAt = '2026-09-28T08:00:00Z'): Email {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: seen ? { $seen: true } : {},
|
||||
receivedAt,
|
||||
from: [{ email: from, name }],
|
||||
subject: `Subject ${id}`,
|
||||
preview: ' line one\n line two ',
|
||||
hasAttachment: false,
|
||||
size: 1,
|
||||
} as Email;
|
||||
}
|
||||
|
||||
describe('toMailItem', () => {
|
||||
it('maps a list email to what the widgets draw', () => {
|
||||
const item = toMailItem(email('1', 'sofia.russo@example.com', 'Sofia Russo', false), 'acc', { threadSize: 2 });
|
||||
expect(item).toMatchObject({
|
||||
id: '1',
|
||||
accountId: 'acc',
|
||||
fromName: 'Sofia Russo',
|
||||
initials: 'SR',
|
||||
unread: true,
|
||||
threadSize: 2,
|
||||
preview: 'line one line two',
|
||||
});
|
||||
// Same hue as the webmail's Avatar for this name.
|
||||
expect(item.color).toBe('#6826d9');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankPeople', () => {
|
||||
it('ranks senders by volume, skips the user and no-reply senders, and counts unread', () => {
|
||||
const people = rankPeople([
|
||||
email('1', 'ada@example.com', 'Ada', false),
|
||||
email('2', 'ada@example.com', 'Ada', true),
|
||||
email('3', 'bob@example.com', 'Bob', false),
|
||||
email('4', 'me@example.com', 'Me', false),
|
||||
email('5', 'noreply@shop.example', 'Shop', false),
|
||||
], ['Me@example.com']);
|
||||
expect(people.map((p) => [p.email, p.unread])).toEqual([['ada@example.com', 1], ['bob@example.com', 1]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('folderCounts', () => {
|
||||
it('orders the roles like the sidebar and finds junk by role or name', () => {
|
||||
const boxes = [
|
||||
{ id: 'j', name: 'Spam', role: 'junk', unreadEmails: 3, totalEmails: 3 },
|
||||
{ id: 'i', name: 'Inbox', role: 'inbox', unreadEmails: 10, totalEmails: 23 },
|
||||
{ id: 'd', name: 'Drafts', role: 'drafts', unreadEmails: 0, totalEmails: 2 },
|
||||
] as Mailbox[];
|
||||
expect(folderCounts(boxes).map((f) => [f.role, f.unread, f.total])).toEqual([
|
||||
['inbox', 10, 23],
|
||||
['drafts', 0, 2],
|
||||
['junk', 3, 3],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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' });
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$comment": "Home-screen widgets. scripts/sync-android-widgets.mjs turns this into the Android provider classes, provider XML, labels and manifest receivers; run it after editing. cols/rows are launcher cells (Android 2x2 is roughly the iOS small widget, 4x2 medium, 4x4 large).",
|
||||
"widgets": [
|
||||
{ "name": "InboxWidget", "label": "Inbox", "description": "Newest messages with sender, time and subject. Taller sizes add avatars and a line of preview.", "cols": 4, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
|
||||
{ "name": "TriageWidget", "label": "Triage", "description": "One unread message at a time with archive, delete and reply buttons.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
|
||||
{ "name": "UnreadCountWidget", "label": "Unread count", "description": "Unread count for the inbox, with the latest senders.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
|
||||
{ "name": "LatestMessageWidget", "label": "Latest message", "description": "Only the newest unread message.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
|
||||
{ "name": "FolderCountsWidget", "label": "Folder counts", "description": "Unread counts for up to four folders.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
|
||||
{ "name": "MailShortcutsWidget", "label": "Shortcuts", "description": "Compose, calendar, contacts and files.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail", "static": true },
|
||||
{ "name": "AllAccountsWidget", "label": "All accounts", "description": "The unified inbox across every signed-in account.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
|
||||
{ "name": "StarredWidget", "label": "Starred", "description": "Starred messages, newest first.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
|
||||
{ "name": "FavouritePeopleWidget", "label": "Favourite people", "description": "The people you hear from most, with their unread count.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
|
||||
{ "name": "MailSearchWidget", "label": "Search mail", "description": "Search bar with your recent searches.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 1, "board": "mail" },
|
||||
{ "name": "OutboxWidget", "label": "Scheduled and outbox", "description": "Scheduled messages and anything waiting in the outbox.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
|
||||
{ "name": "TagWidget", "label": "Tag", "description": "Unread count for your busiest tag.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
|
||||
{ "name": "LastDraftWidget", "label": "Last draft", "description": "Back into the draft you left.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
|
||||
|
||||
{ "name": "AgendaWidget", "label": "Agenda", "description": "Upcoming events by day with time, colour and place.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 2, "board": "calendar" },
|
||||
{ "name": "WeekWidget", "label": "This week", "description": "The week at a glance from 8:00 to 18:00.", "cols": 4, "rows": 2, "minCols": 4, "minRows": 2, "board": "calendar" },
|
||||
{ "name": "MonthWidget", "label": "Month", "description": "Month grid with a dot per calendar and the next two events.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 3, "board": "calendar" },
|
||||
{ "name": "TodayEventsWidget", "label": "Today", "description": "Today's date with the rest of today and tomorrow.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "calendar" },
|
||||
{ "name": "NextEventWidget", "label": "Next event", "description": "What's next, when and where.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "calendar" },
|
||||
{ "name": "DateNextWidget", "label": "Date and next two", "description": "Today's date with the next two events.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "calendar" },
|
||||
{ "name": "UpNextWidget", "label": "Up next", "description": "Countdown to the next event with join and running-late buttons.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "calendar" },
|
||||
|
||||
{ "name": "InvitationsWidget", "label": "Invitations", "description": "Pending invitations with accept, maybe and decline.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "plans" },
|
||||
{ "name": "FreeTimeWidget", "label": "Free time", "description": "When you're free this week.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "plans" },
|
||||
{ "name": "TasksWidget", "label": "Tasks", "description": "Open tasks with tick boxes that work from the home screen.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 2, "board": "plans" },
|
||||
{ "name": "TaskProgressWidget", "label": "Task progress", "description": "How many tasks are done.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "plans" },
|
||||
{ "name": "CountdownWidget", "label": "Countdown", "description": "Days until your next all-day event.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "plans" },
|
||||
{ "name": "BirthdaysWidget", "label": "Birthdays", "description": "Upcoming birthdays from your contacts.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "plans" },
|
||||
|
||||
{ "name": "TodayHubWidget", "label": "Today overview", "description": "Next event, newest unread mail and tasks due, in one widget.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 3, "board": "hubs" },
|
||||
{ "name": "MailAndNextWidget", "label": "Mail and next event", "description": "Unread mail on one side, the next event on the other.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "hubs" },
|
||||
{ "name": "RecentFilesWidget", "label": "Recent files", "description": "Recently changed items in Files.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "hubs" },
|
||||
{ "name": "AttachmentsWidget", "label": "Recent attachments", "description": "The newest attachments from your mail.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "hubs" },
|
||||
{ "name": "VacationWidget", "label": "Vacation responder", "description": "Vacation responder status with an on/off switch.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "hubs" },
|
||||
{ "name": "StorageWidget", "label": "Storage", "description": "How much of your mailbox quota is used.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "hubs" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,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)}` : ''}`,
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
// Views that depend on the current time, computed when a widget draws rather
|
||||
// than when the snapshot was fetched. Pure functions of (snapshot, now), so
|
||||
// they are unit-tested without the widget runtime.
|
||||
|
||||
import { addDays, startOfDay } from './format';
|
||||
import type { EventItem, TaskItem, WidgetSnapshot } from './snapshot';
|
||||
|
||||
const DAY = 86400000;
|
||||
|
||||
/** Events overlapping [dayStart, dayStart + 1 day), all-day first, then by start. */
|
||||
export function eventsOnDay(events: EventItem[], dayStart: number): EventItem[] {
|
||||
const dayEnd = addDays(dayStart, 1);
|
||||
return events
|
||||
.filter((e) => e.start < dayEnd && (e.end > dayStart || (e.end === e.start && e.start >= dayStart)))
|
||||
.sort((a, b) => (a.allDay === b.allDay ? a.start - b.start : a.allDay ? -1 : 1));
|
||||
}
|
||||
|
||||
/** Timed events that have not ended yet, soonest first. */
|
||||
export function upcomingTimed(events: EventItem[], now: number): EventItem[] {
|
||||
return events.filter((e) => !e.allDay && e.end > now).sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
/** The next timed event (running or upcoming) and whether it has started. */
|
||||
export function nextEvent(events: EventItem[], now: number): { event: EventItem; running: boolean } | null {
|
||||
const [event] = upcomingTimed(events, now);
|
||||
if (!event) return null;
|
||||
return { event, running: event.start <= now };
|
||||
}
|
||||
|
||||
export interface AgendaDay {
|
||||
dayStart: number;
|
||||
events: EventItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Days from today with at least one event still relevant (today drops the
|
||||
* events that already ended), up to `maxDays` days with events.
|
||||
*/
|
||||
export function agendaDays(events: EventItem[], now: number, maxDays: number, horizonDays = 14): AgendaDay[] {
|
||||
const out: AgendaDay[] = [];
|
||||
const today = startOfDay(now);
|
||||
for (let i = 0; i < horizonDays && out.length < maxDays; i++) {
|
||||
const day = addDays(today, i);
|
||||
const list = eventsOnDay(events, day).filter((e) => i > 0 || e.allDay || e.end > now);
|
||||
if (list.length > 0) out.push({ dayStart: day, events: list });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Start of the week containing `ms` for the given first weekday (0 Sun, 1 Mon). */
|
||||
export function startOfWeek(ms: number, weekStart: 0 | 1): number {
|
||||
const day = startOfDay(ms);
|
||||
const dow = new Date(day).getDay();
|
||||
const diff = (dow - weekStart + 7) % 7;
|
||||
return addDays(day, -diff);
|
||||
}
|
||||
|
||||
export interface MonthCell {
|
||||
dayStart: number;
|
||||
day: number;
|
||||
inMonth: boolean;
|
||||
isToday: boolean;
|
||||
/** Distinct calendar colours with events that day, at most three. */
|
||||
dots: string[];
|
||||
}
|
||||
|
||||
/** The month containing `now` as whole weeks (5 or 6 rows). */
|
||||
export function monthGrid(events: EventItem[], now: number, weekStart: 0 | 1): MonthCell[][] {
|
||||
const first = new Date(now);
|
||||
first.setDate(1);
|
||||
first.setHours(0, 0, 0, 0);
|
||||
const month = first.getMonth();
|
||||
const today = startOfDay(now);
|
||||
let cursor = startOfWeek(first.getTime(), weekStart);
|
||||
const weeks: MonthCell[][] = [];
|
||||
do {
|
||||
const week: MonthCell[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(cursor);
|
||||
const dots: string[] = [];
|
||||
for (const e of eventsOnDay(events, cursor)) {
|
||||
if (!dots.includes(e.color)) dots.push(e.color);
|
||||
if (dots.length === 3) break;
|
||||
}
|
||||
week.push({
|
||||
dayStart: cursor,
|
||||
day: d.getDate(),
|
||||
inMonth: d.getMonth() === month,
|
||||
isToday: cursor === today,
|
||||
dots,
|
||||
});
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
weeks.push(week);
|
||||
} while (new Date(cursor).getMonth() === month);
|
||||
return weeks;
|
||||
}
|
||||
|
||||
export interface BusyBlock {
|
||||
/** Fraction of the visible window, 0..1. */
|
||||
from: number;
|
||||
to: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timed events of one day clipped to [startHour, endHour) as fractions of that
|
||||
* window - the week and free-time bars.
|
||||
*/
|
||||
export function busyBlocks(events: EventItem[], dayStart: number, startHour = 8, endHour = 18): BusyBlock[] {
|
||||
const winStart = dayStart + startHour * 3600000;
|
||||
const winEnd = dayStart + endHour * 3600000;
|
||||
const span = winEnd - winStart;
|
||||
return eventsOnDay(events, dayStart)
|
||||
.filter((e) => !e.allDay && e.end > winStart && e.start < winEnd)
|
||||
.map((e) => ({
|
||||
from: (Math.max(e.start, winStart) - winStart) / span,
|
||||
to: (Math.min(e.end, winEnd) - winStart) / span,
|
||||
color: e.color,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface FreeTime {
|
||||
/** The next timed event today, if any. */
|
||||
next: EventItem | null;
|
||||
/** True when an event is running right now. */
|
||||
busyNow: boolean;
|
||||
/** Longest free stretch left today inside working hours, if any. */
|
||||
longestGap: { from: number; to: number } | null;
|
||||
}
|
||||
|
||||
export function freeTime(events: EventItem[], now: number, startHour = 8, endHour = 18): FreeTime {
|
||||
const today = startOfDay(now);
|
||||
const winEnd = today + endHour * 3600000;
|
||||
const timed = eventsOnDay(events, today).filter((e) => !e.allDay);
|
||||
const busyNow = timed.some((e) => e.start <= now && e.end > now);
|
||||
const next = timed.find((e) => e.start > now) ?? null;
|
||||
|
||||
let cursor = Math.max(now, today + startHour * 3600000);
|
||||
let longest: { from: number; to: number } | null = null;
|
||||
for (const e of timed.filter((x) => x.end > cursor).sort((a, b) => a.start - b.start)) {
|
||||
if (e.start > cursor) {
|
||||
const gap = { from: cursor, to: Math.min(e.start, winEnd) };
|
||||
if (gap.to > gap.from && (!longest || gap.to - gap.from > longest.to - longest.from)) longest = gap;
|
||||
}
|
||||
cursor = Math.max(cursor, e.end);
|
||||
if (cursor >= winEnd) break;
|
||||
}
|
||||
if (cursor < winEnd) {
|
||||
const gap = { from: cursor, to: winEnd };
|
||||
if (!longest || gap.to - gap.from > longest.to - longest.from) longest = gap;
|
||||
}
|
||||
return { next, busyNow, longestGap: longest };
|
||||
}
|
||||
|
||||
export type DueKind = 'overdue' | 'today' | 'tomorrow' | 'later' | 'none';
|
||||
|
||||
export function dueKind(task: TaskItem, now: number): DueKind {
|
||||
if (task.due === undefined) return 'none';
|
||||
const today = startOfDay(now);
|
||||
const dueDay = startOfDay(task.due);
|
||||
if (task.dueHasTime ? task.due < now : dueDay < today) return 'overdue';
|
||||
if (dueDay === today) return 'today';
|
||||
if (dueDay === addDays(today, 1)) return 'tomorrow';
|
||||
return 'later';
|
||||
}
|
||||
|
||||
/** Open tasks first (overdue, then by due date, undated last), completed after. */
|
||||
export function sortTasks(tasks: TaskItem[], now: number): TaskItem[] {
|
||||
const rank: Record<DueKind, number> = { overdue: 0, today: 1, tomorrow: 2, later: 3, none: 4 };
|
||||
return [...tasks].sort((a, b) => {
|
||||
if (a.done !== b.done) return a.done ? 1 : -1;
|
||||
const ra = rank[dueKind(a, now)];
|
||||
const rb = rank[dueKind(b, now)];
|
||||
if (ra !== rb) return ra - rb;
|
||||
return (a.due ?? Number.MAX_SAFE_INTEGER) - (b.due ?? Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
}
|
||||
|
||||
/** Tasks that count for "today": overdue, due today, or completed today-ish. */
|
||||
export function tasksDueNow(tasks: TaskItem[], now: number): TaskItem[] {
|
||||
return tasks.filter((t) => {
|
||||
const kind = dueKind(t, now);
|
||||
return kind === 'overdue' || kind === 'today';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The event a countdown should point at: the next all-day or multi-day event
|
||||
* that starts after today, else the next timed event on a later day.
|
||||
*/
|
||||
export function countdownTarget(events: EventItem[], now: number): EventItem | null {
|
||||
const tomorrow = addDays(startOfDay(now), 1);
|
||||
const later = events.filter((e) => e.start >= tomorrow).sort((a, b) => a.start - b.start);
|
||||
return later.find((e) => e.allDay || e.end - e.start >= DAY) ?? later[0] ?? null;
|
||||
}
|
||||
|
||||
export function daysUntil(ms: number, now: number): number {
|
||||
return Math.round((startOfDay(ms) - startOfDay(now)) / DAY);
|
||||
}
|
||||
|
||||
/** Birthdays in the next `days` days, soonest first. */
|
||||
export function upcomingBirthdays(snapshot: WidgetSnapshot, now: number, days = 45) {
|
||||
const today = startOfDay(now);
|
||||
const until = addDays(today, days);
|
||||
return snapshot.calendar.birthdays
|
||||
.filter((b) => b.date >= today && b.date < until)
|
||||
.sort((a, b) => a.date - b.date);
|
||||
}
|
||||
@@ -0,0 +1,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}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Tabler Icons (MIT), the set the webmail draws since it moved off Lucide.
|
||||
// Path data is copied from @tabler/icons tabler-nodes-outline.json so the
|
||||
// widgets use the same glyphs as the web UI. Widgets render them through
|
||||
// SvgWidget, which rasterises an SVG string.
|
||||
|
||||
const OUTLINE = {
|
||||
mail: '<path d="M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10"/><path d="M3 7l9 6l9 -6"/>',
|
||||
inbox: '<path d="M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12"/><path d="M4 13h3l3 3h4l3 -3h3"/>',
|
||||
send: '<path d="M10 14l11 -11"/><path d="M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5"/>',
|
||||
file: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/>',
|
||||
fileText: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/><path d="M9 9l1 0"/><path d="M9 13l6 0"/><path d="M9 17l6 0"/>',
|
||||
fileSpreadsheet: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/><path d="M8 11h8v7h-8l0 -7"/><path d="M8 15h8"/><path d="M11 11v7"/>',
|
||||
presentation: '<path d="M3 4l18 0"/><path d="M4 4v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-10"/><path d="M12 16l0 4"/><path d="M9 20l6 0"/><path d="M8 12l3 -3l2 2l3 -3"/>',
|
||||
photo: '<path d="M15 8h.01"/><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12"/><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"/><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"/>',
|
||||
folder: '<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2"/>',
|
||||
calendarClock: '<path d="M10.5 21h-4.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v3"/><path d="M16 3v4"/><path d="M8 3v4"/><path d="M4 11h10"/><path d="M14 18a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"/><path d="M18 16.5v1.5l.5 .5"/>',
|
||||
calendar: '<path d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12"/><path d="M16 3v4"/><path d="M8 3v4"/><path d="M4 11h16"/><path d="M11 15h1"/><path d="M12 15v3"/>',
|
||||
calendarPlain: '<path d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12"/><path d="M16 3v4"/><path d="M8 3v4"/><path d="M4 11h16"/>',
|
||||
archive: '<path d="M3 6a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2"/><path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10"/><path d="M10 12l4 0"/>',
|
||||
trash: '<path d="M4 7l16 0"/><path d="M10 11l0 6"/><path d="M14 11l0 6"/><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"/><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"/>',
|
||||
ban: '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M5.7 5.7l12.6 12.6"/>',
|
||||
search: '<path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/>',
|
||||
edit: '<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415"/><path d="M16 5l3 3"/>',
|
||||
addressBook: '<path d="M20 6v12a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2"/><path d="M10 16h6"/><path d="M11 11a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M4 8h3"/><path d="M4 12h3"/><path d="M4 16h3"/>',
|
||||
server: '<path d="M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2"/><path d="M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2"/><path d="M7 8l0 .01"/><path d="M7 16l0 .01"/><path d="M11 8h6"/><path d="M11 16h6"/>',
|
||||
paperclip: '<path d="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"/>',
|
||||
star: '<path d="M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873l-6.158 -3.245"/>',
|
||||
message: '<path d="M8 9h8"/><path d="M8 13h6"/><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12"/>',
|
||||
mapPin: '<path d="M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/><path d="M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0"/>',
|
||||
video: '<path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4"/><path d="M3 8a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2l0 -8"/>',
|
||||
clock: '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/><path d="M12 7v5l3 3"/>',
|
||||
check: '<path d="M5 12l5 5l10 -10"/>',
|
||||
plus: '<path d="M12 5l0 14"/><path d="M5 12l14 0"/>',
|
||||
chevronLeft: '<path d="M15 6l-6 6l6 6"/>',
|
||||
chevronRight: '<path d="M9 6l6 6l-6 6"/>',
|
||||
users: '<path d="M5 7a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"/><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><path d="M21 21v-2a4 4 0 0 0 -3 -3.85"/>',
|
||||
lock: '<path d="M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6"/><path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0"/><path d="M8 11v-4a4 4 0 1 1 8 0v4"/>',
|
||||
cake: '<path d="M3 20h18v-8a3 3 0 0 0 -3 -3h-12a3 3 0 0 0 -3 3v8"/><path d="M3 14.803c.312 .135 .654 .204 1 .197a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1c.35 .007 .692 -.062 1 -.197"/><path d="M12 4l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737"/>',
|
||||
beach: '<path d="M17.553 16.75a7.5 7.5 0 0 0 -10.606 0"/><path d="M18 3.804a6 6 0 0 0 -8.196 2.196l10.392 6a6 6 0 0 0 -2.196 -8.196"/><path d="M16.732 10c1.658 -2.87 2.225 -5.644 1.268 -6.196c-.957 -.552 -3.075 1.326 -4.732 4.196"/><path d="M15 9l-3 5.196"/><path d="M3 19.25a2.4 2.4 0 0 1 1 -.25a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 1 .25"/>',
|
||||
reply: '<path d="M9 14l-4 -4l4 -4"/><path d="M5 10h11a4 4 0 1 1 0 8h-1"/>',
|
||||
checklist: '<path d="M9.615 20h-2.615a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8"/><path d="M14 19l2 2l4 -4"/><path d="M9 8h4"/><path d="M9 12h2"/>',
|
||||
upload: '<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"/><path d="M7 9l5 -5l5 5"/><path d="M12 4l0 12"/>',
|
||||
refresh: '<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4"/><path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/>',
|
||||
tag: '<path d="M6.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/><path d="M3 6v5.172a2 2 0 0 0 .586 1.414l7.71 7.71a2.41 2.41 0 0 0 3.408 0l5.592 -5.592a2.41 2.41 0 0 0 0 -3.408l-7.71 -7.71a2 2 0 0 0 -1.414 -.586h-5.172a3 3 0 0 0 -3 3"/>',
|
||||
} as const;
|
||||
|
||||
const FILLED = {
|
||||
star: '<path d="M8.243 7.34l-6.38 .925l-.113 .023a1 1 0 0 0 -.44 1.684l4.622 4.499l-1.09 6.355l-.013 .11a1 1 0 0 0 1.464 .944l5.706 -3l5.693 3l.1 .046a1 1 0 0 0 1.352 -1.1l-1.091 -6.355l4.624 -4.5l.078 -.085a1 1 0 0 0 -.633 -1.62l-6.38 -.926l-2.852 -5.78a1 1 0 0 0 -1.794 0l-2.853 5.78z"/>',
|
||||
tag: '<path d="M11.172 2a3 3 0 0 1 2.121 .879l7.71 7.71a3.41 3.41 0 0 1 0 4.822l-5.592 5.592a3.41 3.41 0 0 1 -4.822 0l-7.71 -7.71a3 3 0 0 1 -.879 -2.121v-5.172a4 4 0 0 1 4 -4zm-3.672 3.5a2 2 0 0 0 -1.995 1.85l-.005 .15a2 2 0 1 0 2 -2"/>',
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof OUTLINE;
|
||||
export type FilledIconName = keyof typeof FILLED;
|
||||
|
||||
/** Splits `#rrggbbaa` into an SVG colour plus opacity; rgba() is passed through. */
|
||||
function paint(color: string): { value: string; opacity: string } {
|
||||
const m = /^#([0-9a-f]{6})([0-9a-f]{2})$/i.exec(color);
|
||||
if (m) return { value: `#${m[1]}`, opacity: (parseInt(m[2], 16) / 255).toFixed(3) };
|
||||
return { value: color, opacity: '1' };
|
||||
}
|
||||
|
||||
/** Outline Tabler icon as an SVG string (24 x 24 viewBox, stroke 2 like the web UI). */
|
||||
export function icon(name: IconName, color: string, strokeWidth = 2): string {
|
||||
const { value, opacity } = paint(color);
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" ` +
|
||||
`stroke="${value}" stroke-opacity="${opacity}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round">` +
|
||||
`${OUTLINE[name]}</svg>`
|
||||
);
|
||||
}
|
||||
|
||||
/** Filled Tabler icon (star, tag) as an SVG string. */
|
||||
export function filledIcon(name: FilledIconName, color: string): string {
|
||||
const { value, opacity } = paint(color);
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" ` +
|
||||
`fill="${value}" fill-opacity="${opacity}">${FILLED[name]}</svg>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A progress ring (quota, task progress, countdown). `fraction` is clamped to
|
||||
* 0..1; the arc starts at 12 o'clock like the webmail's quota ring.
|
||||
*/
|
||||
export function ring(fraction: number, track: string, fill: string, strokeWidth = 6): string {
|
||||
const f = Math.max(0, Math.min(1, Number.isFinite(fraction) ? fraction : 0));
|
||||
const r = 32 - strokeWidth / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
const t = paint(track);
|
||||
const p = paint(fill);
|
||||
const arc = f > 0
|
||||
? `<circle cx="32" cy="32" r="${r}" fill="none" stroke="${p.value}" stroke-opacity="${p.opacity}" stroke-width="${strokeWidth}" ` +
|
||||
`stroke-linecap="round" stroke-dasharray="${(f * c).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 32 32)"/>`
|
||||
: '';
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">` +
|
||||
`<circle cx="32" cy="32" r="${r}" fill="none" stroke="${t.value}" stroke-opacity="${t.opacity}" stroke-width="${strokeWidth}"/>` +
|
||||
`${arc}</svg>`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type React from 'react';
|
||||
import type { Fmt } from '../format';
|
||||
import type { WidgetSnapshot } from '../snapshot';
|
||||
import type { WidgetPalette } from '../theme';
|
||||
|
||||
/** Per-widget state kept between draws (e.g. which message the triage card shows). */
|
||||
export type WidgetLocalState = Record<string, unknown>;
|
||||
|
||||
export interface LayoutProps {
|
||||
s: WidgetSnapshot;
|
||||
p: WidgetPalette;
|
||||
f: Fmt;
|
||||
now: number;
|
||||
/** Widget size in dp as the launcher reports it. */
|
||||
width: number;
|
||||
height: number;
|
||||
widgetId: number;
|
||||
local: WidgetLocalState;
|
||||
}
|
||||
|
||||
export type Layout = (props: LayoutProps) => React.JSX.Element;
|
||||
@@ -0,0 +1,26 @@
|
||||
// State that belongs to one placed widget rather than to the data: which
|
||||
// message the triage card is showing. Keyed by the launcher's widget id and
|
||||
// dropped when the widget is removed.
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { WidgetLocalState } from './layouts/types';
|
||||
|
||||
const key = (widgetId: number) => `widgets:local:${widgetId}`;
|
||||
|
||||
export async function loadLocal(widgetId: number): Promise<WidgetLocalState> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(key(widgetId));
|
||||
const parsed = raw ? JSON.parse(raw) : null;
|
||||
return parsed && typeof parsed === 'object' ? (parsed as WidgetLocalState) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLocal(widgetId: number, state: WidgetLocalState): Promise<void> {
|
||||
await AsyncStorage.setItem(key(widgetId), JSON.stringify(state));
|
||||
}
|
||||
|
||||
export async function removeLocal(widgetId: number): Promise<void> {
|
||||
await AsyncStorage.removeItem(key(widgetId));
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||