Compare commits

...
43 Commits
Author SHA1 Message Date
Linus Rath 42f18a78b2 chore: update version number to 0.1.54 2026-07-21 19:55:27 +02:00
Linus Rath 406d2c208a Merge origin/main: keep escape-free inline editor over asset-split fix
PR #20 (c5e5092) fixed #11 on Android by moving the editor page to
android/app/src/main/assets/editor.html, but the real bug was never
'webview scripting broken in release builds': the template literal in
buildEditorHtml cooked the insertLink scheme-regex escapes into an
invalid regex, and that SyntaxError killed the whole inline script.
The PR's buildEditorScript (injectedJavaScript) still contains the
cooked regex, so its theme/init injection dies on Android and the
editor stays completely dead on iOS, whose page no longer has any
inline script.

Resolve by keeping the lib-based editor from the local fix (valid,
escape-free inline script for both platforms, regression-tested in
src/lib/__tests__/editor-html.test.ts, verified on a Pixel 7 API 34
release build) and dropping the now-unused editor.html asset. All
other upstream fixes are kept.
2026-07-21 19:46:55 +02:00
Linus Rath 6f712bf43f fix: compose editor inline script died on parse, keeping Send disabled #11
The template literal in buildEditorHtml cooked the backslash escapes in
the insertLink scheme regex (\/ and \? became bare / and ?), so the page
received /^(https?:|mailto:|tel:|sms:|/|?|#)/i — an invalid regex. The
resulting SyntaxError killed the entire inline editor script: no change
messages ever reached RN, bodyHtml stayed empty, canSend never enabled
without an attachment, and mails sent with one had empty bodies.
Regressed in 29b06a3.

Rewrite the scheme check without backslash escapes and move the page
builder into src/lib/editor-html.ts so a unit test can assert the emitted
script parses and stays backslash-free.

Verified on a Pixel 7 API 34 emulator (release build): typing now posts
change messages and the plain-text body extraction sees the content.
2026-07-21 19:34:51 +02:00
Linus Rath 844cb703c8 Merge pull request #20 from arnauda-gh/fix/android-webview-release-editor
fix(editor): resolve disabled send button and webview scripting on android release builds
2026-07-19 10:12:31 +02:00
Linus Rath 11e5963149 Merge pull request #26 from arnauda-gh/fix/contact-suggestions-view
fix(composer): improve contact suggestion layout and prevent focus/duplicate issues
2026-07-16 19:32:33 +02:00
arnauda-gh 07438e010e fix(composer): improve contact suggestion layout and prevent focus loss & duplicate recipients 2026-07-16 19:05:47 +02:00
Linus Rath 3e33efb71f Merge pull request #17 from arnauda-gh/fix/snackbar-zindex
fix: ensure UndoSnackbar renders on top of compose FAB
2026-07-16 17:57:55 +02:00
Linus Rath 376ee3d678 Merge pull request #18 from arnauda-gh/fix/sender-addresses-scroll
fix: replace identity alert picker with scrollable bottom sheet modal
2026-07-16 17:57:31 +02:00
Linus Rath 6f19cdbd17 Merge pull request #22 from arnauda-gh/fix/duplicate-recipients
fix(composer): prevent duplicate recipients from being added on done/blur action
2026-07-16 17:55:18 +02:00
Linus Rath 6985a7d5f6 Merge pull request #24 from arnauda-gh/fix/swipe-actions-notification
fix(mailboxes): prevent silent swipe failures by notifying user of missing folders
2026-07-16 17:55:05 +02:00
arnauda-gh 8231a55acb fix(mailboxes): show error notifications when special folders are missing on JMAP server 2026-07-16 10:27:30 +02:00
arnauda-gh 1b0740eab4 fix(composer): prevent duplicate recipients from being added on done/blur action 2026-07-16 10:15:04 +02:00
arnauda-gh c5e509274d fix(editor): resolve disabled send button and webview scripting on android release builds 2026-07-16 10:08:17 +02:00
arnauda-gh 1bf5920b04 fix: pre-select primary sender identity matching active account email 2026-07-15 19:43:34 +02:00
arnauda-gh 2547c6f199 fix: replace identity alert picker with scrollable bottom sheet modal 2026-07-15 19:31:54 +02:00
arnauda-gh 69e27a386c fix: ensure UndoSnackbar renders on top of compose FAB 2026-07-15 18:49:52 +02:00
Linus Rath 7390096567 Merge pull request #15 from arnauda-gh/fix/coalesce-token-refresh
fix(auth): coalesce concurrent token refresh requests
2026-07-13 21:18:13 +02:00
arnauda-gh 58c642f27b fix(auth): coalesce concurrent token refresh requests
Coalesces concurrent OAuth token refreshes using a Promise-based lock map keyed by the refresh token. This prevents the app from firing duplicate refresh requests with the same expired refresh token, which causes replay revocations on OIDC providers (e.g. Authelia/Hydra) enforcing strict rotation.

Also optimizes persistRefreshedTokens and ensureFreshCredentials to prevent redundant SecureStore/disk writes when concurrent operations complete.

Fixes #14
2026-07-13 09:05:45 +02:00
Linus Rath b1d1e84666 chore: update version number to 0.1.53 2026-06-17 09:40:10 +02:00
Linus Rath 55484453a1 fix: coalesce concurrent push setups to prevent subscription swarm 2026-06-17 09:39:20 +02:00
Linus Rath b525c52870 chore: update version number to 0.1.52 2026-06-17 09:07:55 +02:00
Linus Rath b64afb462f fix: reap dead push subscriptions from other devices via relay liveness check 2026-06-17 09:07:42 +02:00
Linus Rath b92f85a236 chore: update version number to 0.1.51 2026-06-14 14:46:22 +02:00
Linus Rath 643d704b74 fix: namespace shared calendar ids to avoid collisions with own calendars 2026-06-14 14:46:15 +02:00
Linus Rath 9e383c3616 chore: update version number to 0.1.50 2026-06-14 13:43:36 +02:00
Linus Rath 1543db49d1 fix: match webmail event display logic 2026-06-14 13:43:27 +02:00
Linus Rath 910e5418e9 chore: update version number to 0.1.49 2026-06-12 17:16:03 +02:00
Linus Rath a019d9e775 chore: update version number to 0.1.48 2026-06-12 17:15:53 +02:00
Linus Rath e6d47b6a16 feat: shared calendars, custom recurrence editor, and file sharing 2026-06-12 17:15:50 +02:00
Linus Rath b10f61a642 chore: update version number to 0.1.47 2026-06-12 15:26:57 +02:00
Linus Rath 752aa719d8 feat: pinch zoom and pan for email body content 2026-06-12 15:26:42 +02:00
Linus Rath 5884a18979 refactor: move date/time/size meta from subject row to sender block 2026-06-11 16:33:59 +02:00
Linus Rath 3ccc83e7ce chore: update version number to 0.1.46 2026-06-11 16:24:55 +02:00
Linus Rath 5dc4ea1a45 refactor: replace three-pane pager with FlatList page-snapping pager 2026-06-11 16:24:43 +02:00
Linus Rath 1bd476e052 chore: update version number to 0.1.45 2026-06-11 15:11:28 +02:00
Linus Rath 41793455f7 fix: seamless email swipe transitions with skeleton loading placeholder 2026-06-11 15:11:09 +02:00
Linus Rath d99cbf1dbb chore: update version number to 0.1.44 2026-06-11 14:39:39 +02:00
Linus Rath 1afd2185d5 feat: three-pane pager for seamless email swipe navigation 2026-06-11 14:39:30 +02:00
Linus Rath 5aa1c88b1e chore: update version number to 0.1.43 2026-06-05 19:25:47 +02:00
Linus Rath ac95a2bc39 feat: QR code sign-in 2026-06-05 19:25:00 +02:00
Linus Rath e75aefeb4f chore: update version number to 0.1.42 2026-06-05 16:38:13 +02:00
Linus Rath 70863e0923 feat: swipe navigation between emails and dark-mode emoji/background re-inversion 2026-06-05 16:35:06 +02:00
Linus Rath b6ecf07369 feat: customizable reader quick actions and shrink-to-fit email scaling 2026-06-05 16:21:00 +02:00
52 changed files with 5395 additions and 1002 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.41
0.1.54
+7
View File
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
@@ -21,6 +22,12 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmobile"/>
</intent-filter>
</activity>
<service android:name=".BulwarkMessagingService" android:exported="false">
<intent-filter>
+12 -1
View File
@@ -46,7 +46,18 @@ module.exports = {
web: {
favicon: './assets/favicon.png',
},
plugins: ['expo-secure-store', '@react-native-community/datetimepicker', 'expo-localization'],
plugins: [
'expo-secure-store',
'@react-native-community/datetimepicker',
'expo-localization',
[
'expo-camera',
{
cameraPermission:
'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.',
},
],
],
extra: {
commit: COMMIT,
},
+23 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.41",
"version": "0.1.54",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.41",
"version": "0.1.54",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -17,6 +17,7 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
@@ -5211,6 +5212,26 @@
}
}
},
"node_modules/expo-camera": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
"integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.41",
"version": "0.1.54",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -23,6 +23,7 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
+31 -5
View File
@@ -42,10 +42,10 @@ describe('calendar operations', () => {
});
describe('queryEvents', () => {
it('should send a CalendarEvent/query without server-side filter', async () => {
// Stalwart rejects inCalendars/after/before, so we fetch unfiltered and
// filter client-side. Asserting on the absence of `filter` keeps that
// contract pinned.
it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => {
// Stalwart rejects the draft's plural `inCalendars` filter (and
// after/before); calendars are restricted via singular `inCalendar`
// conditions and date filtering stays client-side.
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
});
@@ -55,9 +55,35 @@ describe('calendar operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[0]).toBe('CalendarEvent/query');
expect(call[1].filter).toBeUndefined();
expect(call[1].filter).toEqual({ inCalendar: 'cal-1' });
expect(call[1].accountId).toBe('acc-1');
});
it('should OR multiple calendars and respect the target account', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
});
await queryEvents(['cal-1', 'cal-2'], '', '', 'acc-shared');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({
operator: 'OR',
conditions: [{ inCalendar: 'cal-1' }, { inCalendar: 'cal-2' }],
});
expect(call[1].accountId).toBe('acc-shared');
});
it('should omit the filter when no calendars are given', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
});
await queryEvents([], '', '');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toBeUndefined();
});
});
describe('getEvents', () => {
+252
View File
@@ -0,0 +1,252 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
hasCapability: vi.fn(),
currentSession: null as unknown,
},
}));
vi.mock('../blob', () => ({
getDownloadUrl: vi.fn(
(blobId: string, _name?: string, _type?: string, accountId?: string) =>
`https://dl/${accountId ?? 'own'}/${blobId}`,
),
uploadBlob: vi.fn(),
uploadBytes: vi.fn(),
}));
import { jmapClient } from '../jmap-client';
import { CAPABILITIES } from '../types';
import {
createFolder,
deleteFileNodes,
getAllFileNodesAcrossAccounts,
getFileNodeDownloadUrl,
getPrincipals,
isCrossAccountId,
isFolder,
setFileNodeShare,
} from '../files';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
function setSession(session: unknown) {
(jmapClient as { currentSession: unknown }).currentSession = session;
}
beforeEach(() => {
vi.clearAllMocks();
mockHasCapability.mockImplementation(
(urn: string) => urn === CAPABILITIES.FILES || urn === CAPABILITIES.PRINCIPALS,
);
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
accounts: {
'acc-1': { name: 'me@example.com', isPersonal: true },
},
});
});
describe('isFolder', () => {
it('treats blob-less nodes as folders and anything with a blob as a file', () => {
// #379: a blob-marked node — even the legacy type:'d' dir markers — is a
// 0-byte file that nothing can be parented under.
expect(isFolder({ blobId: null })).toBe(true);
expect(isFolder({ blobId: undefined })).toBe(true);
expect(isFolder({ blobId: 'blob-1' })).toBe(false);
});
});
describe('createFolder', () => {
it('creates a real container node: no blobId/type/size, parentId set', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']],
});
await createFolder('Docs', 'parent-1');
const [method, args] = mockRequest.mock.calls[0][0][0];
expect(method).toBe('FileNode/set');
expect(args.create['new-dir']).toEqual({ name: 'Docs', parentId: 'parent-1' });
});
it('omits parentId at the root', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']],
});
await createFolder('Docs', null);
const args = mockRequest.mock.calls[0][0][0][1];
expect(args.create['new-dir']).toEqual({ name: 'Docs' });
});
});
describe('deleteFileNodes', () => {
it('lets the server cascade into folder children', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { destroyed: ['f1'] }, '0']],
});
await deleteFileNodes(['f1']);
const args = mockRequest.mock.calls[0][0][0][1];
expect(args.destroy).toEqual(['f1']);
expect(args.onDestroyRemoveChildren).toBe(true);
});
it('throws when the server refuses a destroy', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['FileNode/set', { notDestroyed: { f1: { description: 'forbidden' } } }, '0'],
],
});
await expect(deleteFileNodes(['f1'])).rejects.toThrow('forbidden');
});
});
describe('setFileNodeShare', () => {
it('patches shareWith/{principalId} and requests the principals:owner capability', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
});
const rights = {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
};
await setFileNodeShare('node-1', 'principal-2', rights);
const [calls, using] = mockRequest.mock.calls[0];
expect(calls[0][1].update['node-1']).toEqual({ 'shareWith/principal-2': rights });
expect(using).toContain(CAPABILITIES.PRINCIPALS_OWNER);
});
it('sends null rights to revoke access', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
});
await setFileNodeShare('node-1', 'principal-2', null);
const args = mockRequest.mock.calls[0][0][0][1];
expect(args.update['node-1']).toEqual({ 'shareWith/principal-2': null });
});
it('throws when the server rejects or does not confirm the update', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['FileNode/set', { notUpdated: { 'node-1': { description: 'no mayShare right' } } }, '0'],
],
});
await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow('no mayShare right');
mockRequest.mockResolvedValue({ methodResponses: [['FileNode/set', {}, '0']] });
await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow(
'Server did not confirm the share update',
);
});
});
describe('getAllFileNodesAcrossAccounts', () => {
it('namespaces ids of nodes from other principals and tags them isShared', async () => {
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
accounts: {
'acc-1': { name: 'me@example.com', isPersonal: true },
'acc-2': { name: 'Team', isPersonal: false },
},
});
mockRequest
.mockResolvedValueOnce({
methodResponses: [
['FileNode/get', { list: [{ id: 'own-1', name: 'mine.txt', parentId: null, blobId: 'b1' }] }, '0'],
],
})
.mockResolvedValueOnce({
methodResponses: [
['FileNode/get', {
list: [
{ id: 's1', name: 'Shared Folder', parentId: null, blobId: null },
{ id: 's2', name: 'inside.txt', parentId: 's1', blobId: 'b2' },
],
}, '0'],
],
});
const nodes = await getAllFileNodesAcrossAccounts();
expect(nodes).toHaveLength(3);
const own = nodes.find((n) => n.id === 'own-1')!;
expect(own.isShared).toBe(false);
expect(isCrossAccountId(own.id)).toBe(false);
const folder = nodes.find((n) => n.name === 'Shared Folder')!;
expect(folder.id).toBe('acc-2:s1');
expect(folder.isShared).toBe(true);
expect(folder.accountId).toBe('acc-2');
expect(folder.accountName).toBe('Team');
// parentId is namespaced the same way so child lookups keep working.
const child = nodes.find((n) => n.name === 'inside.txt')!;
expect(child.parentId).toBe('acc-2:s1');
});
it('skips personal accounts without the filenode capability', async () => {
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
accounts: {
'acc-1': { name: 'me@example.com', isPersonal: true },
'acc-3': { name: 'other personal', isPersonal: true },
},
});
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/get', { list: [] }, '0']],
});
await getAllFileNodesAcrossAccounts();
expect(mockRequest).toHaveBeenCalledTimes(1);
expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('acc-1');
});
});
describe('getFileNodeDownloadUrl', () => {
it('routes shared blobs to the owning account', () => {
const url = getFileNodeDownloadUrl({
id: 'acc-2:s2', name: 'inside.txt', type: 'text/plain',
blobId: 'b2', accountId: 'acc-2',
});
expect(url).toBe('https://dl/acc-2/b2');
});
});
describe('getPrincipals', () => {
it('queries then gets via back-reference', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Principal/query', { ids: ['p1'] }, '0'],
['Principal/get', { list: [{ id: 'p1', name: 'other', type: 'individual' }] }, '1'],
],
});
const principals = await getPrincipals();
expect(principals).toEqual([{ id: 'p1', name: 'other', type: 'individual' }]);
const calls = mockRequest.mock.calls[0][0];
expect(calls[0][0]).toBe('Principal/query');
expect(calls[1][0]).toBe('Principal/get');
expect(calls[1][1]['#ids']).toEqual({ resultOf: '0', name: 'Principal/query', path: '/ids' });
});
it('returns empty when the server lacks the principals capability', async () => {
mockHasCapability.mockReturnValue(false);
expect(await getPrincipals()).toEqual([]);
expect(mockRequest).not.toHaveBeenCalled();
});
});
+4 -1
View File
@@ -116,12 +116,15 @@ export function getDownloadUrl(
blobId: string,
name?: string,
type?: string,
// Blobs of nodes shared by another principal live in the owner's account;
// downloading them with our own accountId 404s.
accountId?: string,
): string {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
return session.downloadUrl
.replace('{accountId}', encodeURIComponent(jmapClient.accountId))
.replace('{accountId}', encodeURIComponent(accountId ?? jmapClient.accountId))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent(name ?? 'download'))
.replace('{type}', encodeURIComponent(type ?? 'application/octet-stream'));
+119 -15
View File
@@ -4,6 +4,39 @@ import type { Calendar, CalendarEvent } from './types';
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
/**
* IANA time zone of the device, sent as the `timeZone` argument on
* CalendarEvent/query and CalendarEvent/get. Stalwart interprets LocalDateTime
* filter values and computes utcStart/utcEnd for floating events in this zone,
* defaulting to UTC when absent — which shifts range boundaries and
* floating-event times for any user not in UTC. Stalwart ignores unparseable
* values, so sending it is always safe.
*/
function getUserTimeZone(): string | undefined {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
} catch {
return undefined;
}
}
/**
* Build a CalendarEvent/query filter restricting results to the given
* calendars. Stalwart implements the singular `inCalendar` condition (one
* calendar id per condition), not the draft's plural `inCalendars` array —
* sending the plural form fails the whole query with `unsupportedFilter`.
* Multiple calendars are expressed as an OR of singular conditions.
*/
function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
if (calendarIds.length === 1) {
return { inCalendar: calendarIds[0] };
}
return {
operator: 'OR',
conditions: calendarIds.map((id) => ({ inCalendar: id })),
};
}
const CALENDAR_EVENT_PROPERTIES = [
'id', '@type', 'uid', 'calendarIds', 'title', 'description',
'start', 'duration', 'timeZone', 'showWithoutTime',
@@ -26,43 +59,92 @@ function methodResult<T = any>(res: any, index = 0): T {
return entry[1] as T;
}
// Other session accounts that expose the calendars capability hold calendars
// shared with the user (JMAP surfaces shared data under the sharer's account).
function sharedCalendarAccountIds(): string[] {
const session = jmapClient.currentSession;
const primary = jmapClient.accountId;
return Object.entries(session?.accounts ?? {})
.filter(([id, info]) =>
id !== primary && !!info.accountCapabilities?.[CAPABILITIES.CALENDARS])
.map(([id]) => id);
}
export async function getCalendars(): Promise<Calendar[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/get', { accountId }, '0']],
USING,
);
return methodResult<{ list: Calendar[] }>(res).list ?? [];
const own = methodResult<{ list: Calendar[] }>(res).list ?? [];
// Calendars shared with the user live in other session accounts. Failures
// there (revoked share, transient error) must not hide the user's own
// calendars, so each shared account is fetched best-effort.
const shared = await Promise.all(
sharedCalendarAccountIds().map(async (sharedAccountId) => {
try {
const sharedRes = await jmapClient.request(
[['Calendar/get', { accountId: sharedAccountId }, '0']],
USING,
);
const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? [];
// JMAP calendar ids are only unique within an account, so a shared
// "default" calendar collides with the user's own "default". Namespace
// the id (keeping the real id in originalId) so visibility toggles,
// event-query routing, and create/default mutations don't cross wires.
// Mirrors webmail's getAllCalendars.
return list.map((cal) => ({
...cal,
id: `${sharedAccountId}:${cal.id}`,
originalId: cal.id,
accountId: sharedAccountId,
isShared: true,
}));
} catch {
return [];
}
}),
);
return [...own, ...shared.flat()];
}
export async function queryEvents(
_calendarIds: string[],
calendarIds: string[],
_after: string,
_before: string,
accountId?: string,
): Promise<string[]> {
// Stalwart rejects inCalendars/after/before filters on CalendarEvent/query.
// Fetch all events in the account; calendar visibility + date filtering is
// done client-side.
const accountId = jmapClient.accountId;
// Stalwart rejects after/before filters on CalendarEvent/query; date
// filtering is done client-side. Calendars are restricted via the singular
// `inCalendar` condition (the plural `inCalendars` fails the whole query
// with unsupportedFilter on Stalwart).
const account = accountId || jmapClient.accountId;
const args: Record<string, unknown> = { accountId: account, limit: 1000 };
const timeZone = getUserTimeZone();
if (timeZone) args.timeZone = timeZone;
if (calendarIds.length > 0) args.filter = buildInCalendarFilter(calendarIds);
const res = await jmapClient.request(
[['CalendarEvent/query', { accountId, limit: 1000 }, '0']],
[['CalendarEvent/query', args, '0']],
USING,
);
return methodResult<{ ids: string[] }>(res).ids ?? [];
}
export async function getEvents(ids: string[]): Promise<CalendarEvent[]> {
export async function getEvents(ids: string[], accountId?: string): Promise<CalendarEvent[]> {
if (ids.length === 0) return [];
const accountId = jmapClient.accountId;
const account = accountId || jmapClient.accountId;
const timeZone = getUserTimeZone();
const batchSize = jmapClient.getMaxObjectsInGet();
const all: CalendarEvent[] = [];
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
const res = await jmapClient.request(
[['CalendarEvent/get', {
accountId,
accountId: account,
ids: batch,
properties: CALENDAR_EVENT_PROPERTIES,
...(timeZone ? { timeZone } : {}),
}, '0']],
USING,
);
@@ -91,8 +173,9 @@ export async function createEvent(
event: Partial<CalendarEvent>,
calendarId: string,
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<CalendarEvent> {
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, {
create: {
@@ -117,9 +200,10 @@ export async function createEvent(
export async function batchCreateEvents(
events: Partial<CalendarEvent>[],
calendarId: string,
targetAccountId?: string,
): Promise<number> {
if (events.length === 0) return 0;
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
const create: Record<string, Partial<CalendarEvent>> = {};
events.forEach((e, i) => {
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
@@ -136,8 +220,9 @@ export async function updateEvent(
id: string,
changes: Partial<CalendarEvent> | Record<string, unknown>,
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']],
USING,
@@ -150,8 +235,9 @@ export async function updateEvent(
export async function deleteEvents(
ids: string[],
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']],
USING,
@@ -165,8 +251,9 @@ export async function rsvpEvent(
participantId: string,
status: 'accepted' | 'declined' | 'tentative',
replyTo?: Record<string, string> | null,
targetAccountId?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
// Escape per RFC 6901: ~ → ~0, / → ~1.
const escaped = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
const patch: Record<string, unknown> = {
@@ -225,6 +312,23 @@ export async function createCalendar(
return created;
}
/**
* Mark a calendar as the account default. `isDefault` is read-only in
* Stalwart's Calendar/set — the default is changed via the
* `onSuccessSetIsDefault` request argument instead.
*/
export async function setDefaultCalendar(
calendarId: string,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/set', { accountId, onSuccessSetIsDefault: calendarId }, '0']],
USING,
);
methodResult(res);
}
// Destroy a calendar. `onDestroyEvents: 'destroy'` removes its events too —
// Stalwart otherwise refuses to delete a non-empty calendar.
export async function deleteCalendar(id: string): Promise<void> {
+220 -131
View File
@@ -1,188 +1,277 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { FileNode } from './types';
import { getDownloadUrl, uploadBlob, uploadBytes } from './blob';
import type { FileNode, FileNodeRights, Principal } from './types';
import { getDownloadUrl, uploadBlob } from './blob';
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
// Stalwart and other JMAP servers use one of these strings on FileNode.type to
// mark a directory; everything else is a MIME type.
const DIRECTORY_TYPES = new Set([
'd',
'application/x-directory',
'text/directory',
'httpd/unix-directory',
'inode/directory',
]);
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
const t = node.type;
if (!t) return false;
return DIRECTORY_TYPES.has(t) || t.includes('directory');
// A FileNode is a folder (container) only when it has no blob content — the
// server stores it with `file == null`. Sending a blobId/type/size on create
// (as older builds did: type 'd' + an empty blob) makes a 0-byte FILE that
// nothing can ever be parented under (#379). The webmail migrates those
// legacy flat-named nodes into the real parentId hierarchy on load; this app
// reads and writes only the real hierarchy.
export function isFolder(node: Pick<FileNode, 'blobId'>): boolean {
return node.blobId == null;
}
// Stalwart rejects `/` in FileNode names, so the webmail (and now this app)
// uses U+2215 DIVISION SLASH as the path separator inside the flat `name`
// field. Hierarchy is encoded into the name — there is no `parentId` nesting.
// e.g. a file inside the `Documents` folder is stored with
// `name = "Documents∕report.pdf"`. The display name shown to the user
// strips the prefix.
export const PATH_SEP = '∕';
// Stalwart omits shareWith/myRights from FileNode/get unless they are
// requested explicitly, so the share sheet and indicators must name them here.
const FILE_NODE_PROPERTIES = [
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated',
'shareWith', 'myRights',
];
// Path is a list of folder display names from root. [] is root,
// ['Documents'] is inside Documents, ['Documents','sub'] is inside the
// `sub` folder under Documents.
export function pathToPrefix(path: readonly string[]): string {
if (path.length === 0) return '';
return path.join(PATH_SEP) + PATH_SEP;
export function supportsSharing(): boolean {
return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS);
}
// True when `node` is a direct child (not a descendant) of `prefix`.
export function isDirectChildOfPrefix(node: Pick<FileNode, 'name'>, prefix: string): boolean {
if (!node.name) return false;
if (prefix === '') return !node.name.includes(PATH_SEP);
if (!node.name.startsWith(prefix)) return false;
const rest = node.name.slice(prefix.length);
return rest.length > 0 && !rest.includes(PATH_SEP);
function filesAccountId(): string {
return (
jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.FILES] ??
jmapClient.accountId
);
}
export function nodeDisplayName(node: Pick<FileNode, 'name'>, prefix: string): string {
return prefix && node.name.startsWith(prefix) ? node.name.slice(prefix.length) : node.name;
function fileUsing(): string[] {
const using: string[] = [CAPABILITIES.CORE];
if (jmapClient.hasCapability(CAPABILITIES.FILES)) {
using.push(CAPABILITIES.FILES);
}
// Required for shareWith/myRights on FileNode and for cross-account
// (shared-with-me) FileNode/get.
if (supportsSharing()) {
using.push(CAPABILITIES.PRINCIPALS_OWNER);
}
return using;
}
// Stalwart's FileNode/get does not support a parentId filter and some builds
// omit `type` from the default property set. We fetch the whole tree and
// filter client-side; properties are listed explicitly so `isDirectory()`
// always has a value to look at.
const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated'];
// Nodes fetched from another principal's account are namespaced
// "accountId:nodeId" so they can't collide with the primary account's ids.
// JMAP ids never contain ':', so the separator unambiguously marks a shared
// (cross-account) node.
export function isCrossAccountId(id: string | null | undefined): boolean {
return id != null && id.includes(':');
}
// Fetch every FileNode in the account, files AND folders, to build the
// hierarchy client-side from parentId links.
//
// IMPORTANT: this uses FileNode/get with ids:null (return-all), NOT
// FileNode/query — Stalwart's query only returns leaf files and omits folder
// nodes entirely, which would make every folder invisible.
export async function getAllFileNodes(): Promise<FileNode[]> {
const accountId = jmapClient.accountId;
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
USING,
fileUsing(),
);
return (res.methodResponses[0][1].list ?? []) as FileNode[];
const result = res.methodResponses[0];
if (!result || result[0] === 'error') {
throw new Error(result?.[1]?.description || 'FileNode list failed');
}
return (result[1].list ?? []) as FileNode[];
}
export async function createFileNode(
fileNode: Partial<FileNode>,
): Promise<FileNode> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['FileNode/set', {
accountId,
create: { 'new-file': fileNode },
}, '0']],
USING,
);
const created = res.methodResponses[0][1].created?.['new-file'];
if (!created) {
const err = res.methodResponses[0][1].notCreated?.['new-file'];
throw new Error(err?.description || 'Create failed');
// Accounts (primary + shared/group) that can hold FileNodes: any non-primary
// account that advertises the filenode capability or is non-personal, since
// Stalwart doesn't always advertise capabilities on shared accounts.
function filesCapableAccountIds(): string[] {
const primaryId = filesAccountId();
const accounts = jmapClient.currentSession?.accounts ?? {};
const rest = Object.entries(accounts)
.filter(([id, info]) =>
id !== primaryId &&
(info.accountCapabilities?.[CAPABILITIES.FILES] != null || !info.isPersonal))
.map(([id]) => id);
return [primaryId, ...rest];
}
// Fetch every FileNode the logged-in user can see across all accessible
// accounts. Nodes owned by another principal (shared with the user) are
// tagged `isShared: true` with the owning accountId/accountName, and their
// ids/parentIds are namespaced "accountId:nodeId". Mirrors the webmail's
// listAllFileNodesAcrossAccounts.
export async function getAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
const primaryId = filesAccountId();
const accounts = jmapClient.currentSession?.accounts ?? {};
const all: FileNode[] = [];
for (const accountId of filesCapableAccountIds()) {
const isPrimary = accountId === primaryId;
try {
const res = await jmapClient.request(
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
fileUsing(),
);
const result = res.methodResponses[0];
if (!result || result[0] === 'error') continue;
for (const node of (result[1].list ?? []) as FileNode[]) {
all.push({
...node,
id: isPrimary ? node.id : `${accountId}:${node.id}`,
parentId: node.parentId == null
? null
: (isPrimary ? node.parentId : `${accountId}:${node.parentId}`),
accountId,
accountName: accounts[accountId]?.name || accountId,
isShared: !isPrimary,
});
}
} catch {
// A single unreachable shared account shouldn't hide the user's own
// files; skip it and keep aggregating.
}
}
return created;
return all;
}
export async function createFolder(
displayName: string,
currentPath: readonly string[],
name: string,
parentId: string | null,
): Promise<FileNode> {
// Stalwart's FileNode/set rejects directory creates without a blobId
// ("Missing blob id"), so we upload an empty blob first and reference it.
// Mirrors the webmail's createFileDirectory.
const blob = await uploadBytes(new Uint8Array(0), 'application/x-directory');
const fullName = pathToPrefix(currentPath) + displayName;
return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 });
const accountId = filesAccountId();
const props: Record<string, unknown> = { name };
if (parentId !== null) props.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/set', { accountId, create: { 'new-dir': props } }, '0']],
fileUsing(),
);
const result = res.methodResponses[0][1];
const created = result.created?.['new-dir'];
if (!created) {
const err = result.notCreated?.['new-dir'];
throw new Error(err?.description || 'Create folder failed');
}
return { ...props, ...created } as FileNode;
}
export async function updateFileNode(
id: string,
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
USING,
fileUsing(),
);
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
}
// Rename a node and, if it's a folder, cascade the rename to every descendant
// so the encoded path stays consistent. The webmail does this same dance.
export async function renameFileNode(
node: FileNode,
newDisplayName: string,
currentPath: readonly string[],
allNodes: readonly FileNode[],
): Promise<void> {
const prefix = pathToPrefix(currentPath);
const oldServerName = node.name;
const newServerName = prefix + newDisplayName;
if (oldServerName === newServerName) return;
await updateFileNode(node.id, { name: newServerName });
if (isDirectory(node)) {
const oldFolderPrefix = oldServerName + PATH_SEP;
const newFolderPrefix = newServerName + PATH_SEP;
for (const n of allNodes) {
if (n.id === node.id) continue;
if (n.name.startsWith(oldFolderPrefix)) {
await updateFileNode(n.id, { name: newFolderPrefix + n.name.slice(oldFolderPrefix.length) });
}
}
}
export async function renameFileNode(id: string, newName: string): Promise<void> {
await updateFileNode(id, { name: newName });
}
export async function deleteFileNodes(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
await jmapClient.request(
[['FileNode/set', { accountId, destroy: ids }, '0']],
USING,
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/set', {
accountId,
destroy: ids,
// The server removes descendant nodes of destroyed folders.
onDestroyRemoveChildren: true,
}, '0']],
fileUsing(),
);
}
// Delete `targets` and every descendant. Stalwart doesn't enforce parent
// references, so we collect descendants by name-prefix and destroy them all
// in one call. Without this, deleting a folder leaves its files orphaned and
// still discoverable at the old encoded path.
export async function deleteWithDescendants(
targets: readonly FileNode[],
allNodes: readonly FileNode[],
): Promise<void> {
const ids = new Set<string>();
for (const node of targets) {
ids.add(node.id);
if (isDirectory(node)) {
const folderPrefix = node.name + PATH_SEP;
for (const n of allNodes) {
if (n.name.startsWith(folderPrefix)) ids.add(n.id);
}
}
const notDestroyed = res.methodResponses[0][1].notDestroyed as
| Record<string, { description?: string }>
| undefined;
const failedIds = Object.keys(notDestroyed ?? {});
if (failedIds.length > 0) {
throw new Error(
notDestroyed![failedIds[0]]?.description || `Failed to delete ${failedIds.length} item(s)`,
);
}
await deleteFileNodes(Array.from(ids));
}
export function getFileNodeDownloadUrl(node: FileNode): string {
if (!node.blobId) throw new Error('Folders cannot be downloaded');
return getDownloadUrl(node.blobId, node.name, node.type);
return getDownloadUrl(node.blobId, node.name, node.type, node.accountId);
}
export async function uploadFileNode(
uri: string,
displayName: string,
name: string,
mimeType: string,
currentPath: readonly string[],
parentId: string | null,
): Promise<FileNode> {
const accountId = filesAccountId();
const blob = await uploadBlob(uri, mimeType);
const fullName = pathToPrefix(currentPath) + displayName;
return createFileNode({
name: fullName,
type: blob.type || mimeType || 'application/octet-stream',
const type = blob.type || mimeType || 'application/octet-stream';
const props: Record<string, unknown> = {
name,
// Stalwart caps the stored MIME type; very long types fail the create.
type: type.length > 30 ? 'application/octet-stream' : type,
blobId: blob.blobId,
size: blob.size,
});
};
if (parentId !== null) props.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/set', { accountId, create: { 'new-file': props } }, '0']],
fileUsing(),
);
const result = res.methodResponses[0][1];
const created = result.created?.['new-file'];
if (!created) {
const err = result.notCreated?.['new-file'];
throw new Error(err?.description || 'Upload failed');
}
return { ...props, ...created } as FileNode;
}
// ── Sharing (RFC 9670) ────────────────────────────────────
// Add, update, or remove a principal's rights on an owned FileNode (file or
// folder). Pass rights: null to revoke. Stalwart applies it via a
// `shareWith/{principalId}` patch on FileNode/set. Sharing a folder shares
// its whole subtree — which is why this app must store real parentId
// hierarchy rather than the legacy flat-name encoding.
export async function setFileNodeShare(
fileNodeId: string,
principalId: string,
rights: FileNodeRights | null,
): Promise<void> {
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/set', {
accountId,
update: { [fileNodeId]: { [`shareWith/${principalId}`]: rights } },
}, '0']],
fileUsing(),
);
const result = res.methodResponses[0][1];
if (result.notUpdated?.[fileNodeId]) {
throw new Error(result.notUpdated[fileNodeId].description || 'Failed to update file share');
}
if (!result.updated || !(fileNodeId in result.updated)) {
throw new Error('Server did not confirm the share update');
}
}
// List all principals visible to the user. Stalwart returns the full
// directory regardless of `filter`, so callers filter client-side.
export async function getPrincipals(): Promise<Principal[]> {
if (!supportsSharing()) return [];
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[
['Principal/query', { accountId }, '0'],
['Principal/get', {
accountId,
'#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' },
}, '1'],
],
[CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS],
);
const getResp = res.methodResponses.find((r) => r[0] === 'Principal/get');
return (getResp?.[1].list ?? []) as Principal[];
}
// The principal id that represents the logged-in user (excluded from the
// share picker — sharing with yourself is a no-op the server may reject).
export function ownPrincipalId(): string {
return filesAccountId();
}
+1
View File
@@ -151,6 +151,7 @@ export class JMAPClient {
private async persistRefreshedTokens(next: OAuthTokens): Promise<void> {
if (!this.credentials) return;
if (this.credentials.accessToken === next.accessToken) return;
this.credentials = {
...this.credentials,
accessToken: next.accessToken,
+63
View File
@@ -405,6 +405,7 @@ export interface VirtualLocation {
}
export interface RecurrenceRule {
'@type'?: 'RecurrenceRule';
frequency: string;
interval?: number;
until?: string;
@@ -419,6 +420,8 @@ export interface RecurrenceRule {
bySecond?: number[];
bySetPosition?: number[];
firstDayOfWeek?: string;
rscale?: string;
skip?: string;
}
export interface Alert {
@@ -452,6 +455,9 @@ export interface CalendarEvent {
excludedRecurrenceRules?: RecurrenceRule[];
recurrenceId?: string;
originalId?: string;
// Client-only: the event's server-side calendarIds (raw, unprefixed), kept
// when `calendarIds` is remapped to namespaced store ids for shared events.
originalCalendarIds?: Record<string, boolean>;
useDefaultAlerts?: boolean;
alerts?: Record<string, Alert>;
locations?: Record<string, EventLocation>;
@@ -467,6 +473,12 @@ export interface CalendarEvent {
color?: string;
// Client-only: account the event/occurrence belongs to (for shared/virtual).
localAccountId?: string;
// Client-only: JMAP account the event was fetched from. Absent for the
// primary account; set for events on calendars shared with the user so
// mutations can be routed to the owning account.
accountId?: string;
// Client-only: true when the event lives on a calendar shared with the user.
isShared?: boolean;
}
export interface CalendarRights {
@@ -492,6 +504,21 @@ export interface Calendar {
isDefault?: boolean;
sortOrder?: number;
myRights?: CalendarRights;
// Client-only: JMAP account the calendar was fetched from. Absent for the
// primary account; set for calendars shared with the user.
accountId?: string;
// Client-only: the real JMAP id of a shared calendar, before its `id` was
// namespaced as `${accountId}:${id}` to stay unique in the store. Mutations
// and event queries must use this raw id against the owning account.
originalId?: string;
// Client-only: true when the calendar lives in another account (shared
// with the user by its owner).
isShared?: boolean;
// Client-only: set when `color` has been replaced by the viewer's local
// override for a shared calendar (see lib/calendar-utils). When true, the
// override wins over per-event colors so the whole shared calendar paints
// uniformly.
colorIsLocalOverride?: boolean;
}
// ─── Files (FileNode) ───────────────────────────────────
@@ -505,6 +532,40 @@ export interface FileNode {
size?: number;
created?: string;
updated?: string;
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
// principals capability and the properties are requested explicitly. A node
// is shared-out when `shareWith` has entries; `myRights` describes what the
// viewer may do (always full rights on owned nodes).
myRights?: FileNodeRights;
shareWith?: Record<string, FileNodeRights> | null;
// True when this node was fetched from another principal's account that was
// shared with the logged-in user ("Shared with me").
isShared?: boolean;
// Owning account's JMAP id and display name, set when aggregating nodes
// across accounts so blob downloads route to the right account.
accountId?: string;
accountName?: string;
}
// FileNode rights as defined by Stalwart's JmapSharedObject implementation.
export interface FileNodeRights {
mayRead: boolean;
mayAddChildren: boolean;
mayRename: boolean;
mayDelete: boolean;
mayModifyContent: boolean;
mayShare: boolean;
}
// RFC 9670 principal — an entity (user, group, resource) that files and
// calendars can be shared with. On Stalwart the principal id doubles as the
// account id of that user/group.
export interface Principal {
id: string;
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
name: string;
description?: string | null;
email?: string | null;
}
// ─── Push / SSE ─────────────────────────────────────────
@@ -538,4 +599,6 @@ export const CAPABILITIES = {
SIEVE: 'urn:ietf:params:jmap:sieve',
QUOTA: 'urn:ietf:params:jmap:quota',
FILES: 'urn:ietf:params:jmap:filenode',
PRINCIPALS: 'urn:ietf:params:jmap:principals',
PRINCIPALS_OWNER: 'urn:ietf:params:jmap:principals:owner',
} as const;
+4 -1
View File
@@ -101,7 +101,10 @@ async function ensureFreshCredentials(
tokenEndpoint: next.tokenEndpoint,
clientId: next.clientId,
};
await jmapClient.setStoredCredentials(accountId, updated);
const current = await jmapClient.getStoredCredentials(accountId);
if (!current || current.accessToken !== updated.accessToken) {
await jmapClient.setStoredCredentials(accountId, updated);
}
return updated;
} catch {
// Fall back to the existing (possibly expired) token; the request will
+371 -7
View File
@@ -3,7 +3,7 @@ import { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-nati
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
import type { Email } from '../api/types';
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody, hasNativeDarkMode } from '../lib/email-html';
import { jmapClient } from '../api/jmap-client';
import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store';
@@ -27,6 +27,15 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
interface EmailBodyViewProps {
email: Email;
senderEmail?: string;
// The body is a native WebView, which on Android swallows horizontal touches
// before the surrounding pager can see them. We detect a clear horizontal
// swipe inside the page and report it here so the pager can change messages.
onSwipe?: (direction: 'prev' | 'next') => void;
// Pinch-zoom state from inside the page: `pinching` while two fingers are
// down, `zoomed` while the content is scaled in. The hosts use it to freeze
// the surrounding vertical scroll / horizontal pager so a zoom gesture can't
// scroll the pane away or switch mails.
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
}
function extractHtmlBody(email: Email): string | null {
@@ -45,29 +54,188 @@ function extractTextBody(email: Email): string | null {
return null;
}
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
// body's `filter: invert(1)` flips colored emoji (yellow smiley -> blue, red
// heart -> cyan) and stylesheet-defined background images, which the static CSS
// attribute selectors can't reach. This walks the DOM once and wraps/re-inverts
// those so they keep their original colors. Only injected when inversion is on.
const DARK_REINVERT_SCRIPT = `
(function () {
if (document.__rnDarkInvertDone) return;
var doc = document;
var win = window;
if (!doc.body) return;
document.__rnDarkInvertDone = true;
// Re-invert elements with stylesheet-defined background images (CSS attribute
// selectors only catch inline styles, not rules that survive in <style>).
doc.body.querySelectorAll('*').forEach(function (el) {
// Skip elements already handled by CSS attribute selectors.
if (el.style.backgroundImage || el.style.background ||
el.hasAttribute('background') || el.hasAttribute('bgcolor')) return;
// Skip leaf media elements (already re-inverted by CSS).
var tag = el.tagName;
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
var computed = win.getComputedStyle(el);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Only re-invert if this container doesn't have media children.
if (!el.querySelector('img, video, svg, canvas, object, embed')) {
el.style.filter = 'invert(1) hue-rotate(180deg)';
}
}
});
// Re-invert emoji glyphs so they keep their original colors. Wrap each emoji
// run in a span that re-inverts. Only act when the ancestor invert depth is
// odd - emojis inside a double-inverted bgcolor container already render at
// their original colors.
var emojiRe;
try {
emojiRe = new RegExp('\\\\p{RGI_Emoji}', 'gv');
} catch (e) {
emojiRe = /\\p{Extended_Pictographic}(?:\\uFE0F)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F)?)*/gu;
}
var emojiTestRe = /\\p{Extended_Pictographic}/u;
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1, IFRAME: 1 };
function isOddInvertDepth(start) {
var count = 0;
var n = start;
while (n) {
if (n === doc.body) { count++; break; }
var cs = win.getComputedStyle(n);
if (cs.filter && cs.filter.indexOf('invert') !== -1) count++;
n = n.parentElement;
}
return count % 2 === 1;
}
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
acceptNode: function (node) {
var p = node.parentElement;
while (p) {
if (SKIP_TAGS[p.tagName]) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return emojiTestRe.test(node.nodeValue || '')
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
});
var emojiTextNodes = [];
var cur;
while ((cur = walker.nextNode())) emojiTextNodes.push(cur);
emojiTextNodes.forEach(function (textNode) {
var parent = textNode.parentElement;
if (!parent || !isOddInvertDepth(parent)) return;
var text = textNode.nodeValue || '';
emojiRe.lastIndex = 0;
var frag = doc.createDocumentFragment();
var lastIndex = 0;
var m;
while ((m = emojiRe.exec(text)) !== null) {
if (m.index > lastIndex) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
}
var span = doc.createElement('span');
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
span.textContent = m[0];
frag.appendChild(span);
lastIndex = m.index + m[0].length;
}
if (lastIndex === 0) return;
if (lastIndex < text.length) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
}
parent.replaceChild(frag, textNode);
});
})();
`;
const HEIGHT_REPORTER = `
(function () {
// One-time: wrap body contents in a block-level div we can measure reliably.
// body.scrollHeight on Android WebView is pinned to the container viewport
// height, so we can't trust it. A dedicated wrapper gives a deterministic
// bounding box.
var wrapperEl = null;
function ensureWrapper() {
var b = document.body;
if (!b) return null;
if (wrapperEl && wrapperEl.isConnected) return wrapperEl;
var wrap = document.getElementById('__rn_email_body_wrap__');
if (!wrap) {
wrap = document.createElement('div');
wrap.id = '__rn_email_body_wrap__';
wrap.style.display = 'block';
wrap.style.width = '100%';
wrap.style.transformOrigin = 'top left';
while (b.firstChild) wrap.appendChild(b.firstChild);
b.appendChild(wrap);
}
wrapperEl = wrap;
return wrap;
}
// The width available for the email content (body content-box).
function availWidth() {
var b = document.body;
var cs = window.getComputedStyle(b);
var padL = parseFloat(cs.paddingLeft) || 0;
var padR = parseFloat(cs.paddingRight) || 0;
return b.clientWidth - padL - padR;
}
// Shared zoom state, mutated by the pinch-zoom handler injected after this
// script. "fit" is the shrink-to-fit scale, "scale" the user's pinch factor
// on top of it, "tx" pans the zoomed overflow into view horizontally.
// Vertical panning needs no state here: the height reports below carry the
// scaled height, so the surrounding native ScrollView grows and scrolls.
var Z = window.__rnZoom = window.__rnZoom || {
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
};
// Apply the combined fit × pinch transform using the cached layout numbers
// (cheap — no reflow), clamping the pan so content edges pin to the viewport.
function applyTransform(w) {
var s = Z.fit * Z.scale;
if (Z.scale > 1.001 || Z.fit < 0.999) {
var minTx = Math.min(0, Z.avail - Z.layoutW * s);
if (Z.tx < minTx) Z.tx = minTx;
if (Z.tx > 0) Z.tx = 0;
w.style.width = Z.layoutW + 'px';
w.style.transform = 'translateX(' + Z.tx + 'px) scale(' + s + ')';
} else {
Z.tx = 0;
w.style.width = '100%';
w.style.transform = 'none';
}
}
// Shrink-to-fit: emails authored at a fixed width (e.g. 600px tables) overflow
// a phone viewport and force horizontal scrolling. Lay the content out at its
// natural width, then scale the whole block down so its widest element fits
// the screen — the way native mail clients do. No sideways scrolling.
function fitToWidth(w) {
if (!w) return;
var avail = availWidth();
if (avail <= 0) return;
// Reset to full width so wrapping content reports its true overflow extent
// (the widest unbreakable element) rather than a previously-scaled box.
w.style.width = '100%';
var content = w.scrollWidth;
Z.avail = avail;
Z.layoutW = content > avail + 1 ? content : avail;
Z.fit = avail / Z.layoutW;
applyTransform(w);
}
function measure() {
var w = ensureWrapper();
if (!w) return 0;
// While a pinch is in flight only re-apply the transform — a full re-fit
// resets the wrapper width and would thrash layout on every frame.
if (Z.pinching) applyTransform(w);
else fitToWidth(w);
// getBoundingClientRect reflects the applied transform, so r.height is the
// already-scaled visual height of the content.
var r = w.getBoundingClientRect();
var b = document.body;
var cs = window.getComputedStyle(b);
@@ -117,7 +285,16 @@ const HEIGHT_REPORTER = `
if (document.body) ro.observe(document.body);
} catch (e) {}
try {
var mo = new MutationObserver(report);
var mo = new MutationObserver(function (records) {
for (var i = 0; i < records.length; i++) {
// Ignore the style mutations fitToWidth() applies to the wrapper itself,
// otherwise scaling would re-trigger a measure and loop indefinitely.
var rec = records[i];
if (rec.target === wrapperEl && rec.type === 'attributes') continue;
report();
return;
}
});
if (document.body) mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
} catch (e) {}
try {
@@ -126,11 +303,161 @@ const HEIGHT_REPORTER = `
img.addEventListener('error', report);
});
} catch (e) {}
// Hooks for the pinch-zoom handler script.
window.__rnApplyZoom = function () { var w = ensureWrapper(); if (w) applyTransform(w); };
window.__rnReport = report;
true;
})();
`;
export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps) {
// Detect a clear horizontal flick inside the WebView and report it. Uses
// capture-phase, passive listeners so it never blocks vertical scrolling, text
// selection, or link taps — it only reacts to a quick, horizontal-dominant
// gesture on release. Guarded so re-injection (onLoadEnd) is a no-op.
const SWIPE_DETECTOR = `
(function () {
if (window.__rnSwipeInit) return;
window.__rnSwipeInit = true;
var x0 = 0, y0 = 0, t0 = 0, tracking = false;
document.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { tracking = false; return; }
var t = e.touches[0];
x0 = t.clientX; y0 = t.clientY; t0 = Date.now(); tracking = true;
}, { passive: true, capture: true });
document.addEventListener('touchend', function (e) {
if (!tracking) return;
tracking = false;
// While the content is pinch-zoomed, horizontal flicks pan the zoomed
// content — never switch mails.
if (window.__rnZoom && (window.__rnZoom.pinching || window.__rnZoom.scale > 1.001)) return;
var t = e.changedTouches[0];
if (!t) return;
var dx = t.clientX - x0, dy = t.clientY - y0, dt = Date.now() - t0;
if (dt < 700 && Math.abs(dx) > 55 && Math.abs(dx) > Math.abs(dy) * 1.8) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'swipe', dir: dx < 0 ? 'next' : 'prev' }));
}
}
}, { passive: true, capture: true });
true;
})();
`;
// Pinch zoom + pan for the email content, driven entirely in the page: native
// WebView zoom can't work here because the WebView is auto-height inside a
// native ScrollView (viewport meta pins it to scale 1). A two-finger pinch
// scales the measured wrapper (on top of the shrink-to-fit scale the height
// reporter maintains) anchored at the fingers' horizontal midpoint; while
// zoomed, one-finger horizontal drags pan via translateX and vertical drags
// still scroll the surrounding ScrollView, which sees the scaled height
// through the regular height reports. Pinch moves call preventDefault so the
// engine consumes the gesture and Android parents don't intercept it; `zoom`
// messages let the RN side freeze the pane scroll / pager as a second guard.
const PINCH_ZOOM = `
(function () {
if (window.__rnPinchInit) return;
window.__rnPinchInit = true;
var Z = window.__rnZoom = window.__rnZoom || {
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
};
var MAX_ZOOM = 4;
var pinch = null; // { d0, s0, cx0, tx0 }
var pan = null; // { x0, tx0 }
var rafPending = false;
function post(msg) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
}
}
function apply() {
if (window.__rnApplyZoom) window.__rnApplyZoom();
}
// Height reports are rAF-throttled during the gesture so the RN container
// keeps up with the growing content without re-measuring on every move.
function scheduleReport() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(function () {
rafPending = false;
if (window.__rnReport) window.__rnReport();
});
}
function dist(t0, t1) {
var dx = t1.clientX - t0.clientX, dy = t1.clientY - t0.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
document.addEventListener('touchstart', function (e) {
if (e.touches.length === 2) {
var t0 = e.touches[0], t1 = e.touches[1];
pinch = {
d0: dist(t0, t1) || 1,
s0: Z.scale,
cx0: (t0.clientX + t1.clientX) / 2,
tx0: Z.tx,
};
pan = null;
if (!Z.pinching) {
Z.pinching = true;
post({ type: 'zoom', pinching: true, scale: Z.scale });
}
e.preventDefault();
} else if (e.touches.length === 1 && Z.scale > 1.001) {
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
} else {
pan = null;
}
}, { passive: false, capture: true });
document.addEventListener('touchmove', function (e) {
if (pinch && e.touches.length === 2) {
e.preventDefault();
var t0 = e.touches[0], t1 = e.touches[1];
var z = pinch.s0 * (dist(t0, t1) / pinch.d0);
if (z < 1) z = 1;
if (z > MAX_ZOOM) z = MAX_ZOOM;
// Keep the content under the fingers' midpoint stationary horizontally:
// the wrapper-local point that started under cx0 must map back to the
// current midpoint at the new scale. (applyTransform clamps the result.)
var cx = (t0.clientX + t1.clientX) / 2;
var local = (pinch.cx0 - pinch.tx0) / (Z.fit * pinch.s0);
Z.scale = z;
Z.tx = cx - local * (Z.fit * z);
apply();
scheduleReport();
} else if (pan && e.touches.length === 1 && Z.scale > 1.001) {
// No preventDefault: vertical-dominant drags must keep scrolling the
// native ScrollView around the WebView. Horizontal-dominant drags stay
// inside the WebView and pan here.
Z.tx = pan.tx0 + (e.touches[0].clientX - pan.x0);
apply();
}
}, { passive: false, capture: true });
function onTouchEnd(e) {
if (pinch && e.touches.length < 2) {
pinch = null;
// Snap back to the fitted layout when the pinch ends near scale 1.
if (Z.scale < 1.02) { Z.scale = 1; Z.tx = 0; }
Z.pinching = false;
apply();
if (window.__rnReport) window.__rnReport();
post({ type: 'zoom', pinching: false, scale: Z.scale });
// A finger left on screen continues as a pan.
if (e.touches.length === 1 && Z.scale > 1.001) {
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
}
}
if (e.touches.length === 0) pan = null;
}
document.addEventListener('touchend', onTouchEnd, { passive: true, capture: true });
document.addEventListener('touchcancel', onTouchEnd, { passive: true, capture: true });
true;
})();
`;
export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChange }: EmailBodyViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
@@ -273,8 +600,36 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
};
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark]);
// Inversion is applied for HTML bodies in dark mode unless the email ships its
// own dark-mode CSS. When it's on, prepend the DOM re-inversion pass (emoji +
// stylesheet backgrounds) so it runs before the height reporter measures.
const applyInversion =
!!rawHtml && renderAsDark && !hasNativeDarkMode(rawHtml);
const injectedJs = React.useMemo(
() =>
(applyInversion ? DARK_REINVERT_SCRIPT + HEIGHT_REPORTER : HEIGHT_REPORTER)
+ SWIPE_DETECTOR + PINCH_ZOOM,
[applyInversion],
);
const onMessage = (e: WebViewMessageEvent) => {
const parsed = parseInt(e.nativeEvent.data, 10);
const data = e.nativeEvent.data;
// Swipe/zoom messages arrive as JSON; the height reporter posts a bare number.
if (data.charCodeAt(0) === 123 /* '{' */) {
try {
const msg = JSON.parse(data);
if (msg.type === 'swipe' && (msg.dir === 'prev' || msg.dir === 'next')) {
onSwipe?.(msg.dir);
} else if (msg.type === 'zoom') {
onZoomChange?.({
pinching: !!msg.pinching,
zoomed: typeof msg.scale === 'number' && msg.scale > 1.01,
});
}
} catch { /* ignore malformed */ }
return;
}
const parsed = parseInt(data, 10);
if (Number.isNaN(parsed) || parsed <= 0) return;
setHeight((prev) => (Math.abs(parsed - prev) < 2 ? prev : parsed));
};
@@ -316,16 +671,25 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
originWhitelist={['about:blank']}
source={source}
javaScriptEnabled
injectedJavaScript={HEIGHT_REPORTER}
injectedJavaScript={injectedJs}
onMessage={onMessage}
onLoadEnd={() => {
// A (re)load resets the page's zoom state to 1 — drop any host-side
// scroll locks so they can't go stale (e.g. cid images arriving
// reload the source mid-zoom).
onZoomChange?.({ pinching: false, zoomed: false });
// Re-inject after load to cover Android cases where
// `injectedJavaScript` runs too early to see the final layout.
// `injectedJavaScript` runs too early to see the final layout. The
// re-inversion pass guards itself (__rnDarkInvertDone) so running it
// again here is a no-op once the body has been processed.
setTimeout(() => {
webviewRef.current?.injectJavaScript(HEIGHT_REPORTER);
webviewRef.current?.injectJavaScript(injectedJs);
}, 50);
}}
scrollEnabled={false}
// Native Android zoom would scale the viewport inside the fixed-size
// container; pinch zoom is implemented in the page instead.
setBuiltInZoomControls={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
setSupportMultipleWindows={false}
+205
View File
@@ -0,0 +1,205 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Check, Mail } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Identity } from '../api/types';
import { useLocaleStore } from '../stores/locale-store';
interface IdentitySheetProps {
visible: boolean;
onClose: () => void;
identities: Identity[];
selectedIdentityId: string | null;
onPick: (identity: Identity) => void;
}
export function IdentitySheet({
visible, onClose, identities, selectedIdentityId, onPick,
}: IdentitySheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
const { t } = useLocaleStore();
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('email_composer.from', 'From')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView style={styles.scrollList}>
{identities.map((identity) => {
const isSelected = identity.id === selectedIdentityId;
return (
<Pressable
key={identity.id}
onPress={() => {
onPick(identity);
onClose();
}}
style={({ pressed }) => [
styles.identityRow,
pressed && styles.identityRowPressed,
]}
>
<View style={styles.iconWrap}>
<Mail size={16} color={isSelected ? c.primary : c.textSecondary} />
</View>
<View style={styles.identityDetails}>
{!!identity.name && (
<Text style={styles.identityName} numberOfLines={1}>
{identity.name}
</Text>
)}
<Text style={[styles.identityEmail, !identity.name && styles.identityEmailOnly]} numberOfLines={1}>
{identity.email}
</Text>
</View>
{isSelected && (
<View style={styles.checkWrap}>
<Check size={16} color={c.primary} />
</View>
)}
</Pressable>
);
})}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
sheetOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
sheetOverlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '60%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
sheetHandleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
paddingBottom: spacing.sm,
},
sheetHandle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: c.border,
},
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
sheetTitle: {
...typography.bodySemibold,
color: c.text,
},
sheetClose: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.xs,
},
scrollList: {
paddingVertical: spacing.xs,
},
identityRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
minHeight: 52,
},
identityRowPressed: {
backgroundColor: c.surfaceHover,
},
iconWrap: {
marginRight: spacing.md,
width: 28,
height: 28,
borderRadius: radius.xs,
backgroundColor: c.surface,
alignItems: 'center',
justifyContent: 'center',
},
identityDetails: {
flex: 1,
justifyContent: 'center',
},
identityName: {
...typography.bodyMedium,
color: c.text,
},
identityEmail: {
...typography.caption,
color: c.textSecondary,
marginTop: 2,
},
identityEmailOnly: {
...typography.bodyMedium,
color: c.text,
marginTop: 0,
},
checkWrap: {
marginLeft: spacing.md,
},
});
}
+124
View File
@@ -0,0 +1,124 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import Button from './Button';
interface QrScanModalProps {
visible: boolean;
onClose: () => void;
// Fires once with the raw decoded QR string. The parent is responsible for
// closing the modal and interpreting the payload.
onScanned: (data: string) => void;
}
export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [permission, requestPermission] = useCameraPermissions();
// Guards against the camera firing onBarcodeScanned dozens of times for the
// same code before the modal tears down.
const handled = React.useRef(false);
React.useEffect(() => {
if (visible) handled.current = false;
}, [visible]);
const handleBarcode = React.useCallback(
(result: BarcodeScanningResult) => {
if (handled.current) return;
handled.current = true;
onScanned(result.data);
},
[onScanned],
);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}>
{permission?.granted ? (
<CameraView
style={StyleSheet.absoluteFill}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarcode}
/>
) : null}
<SafeAreaView style={styles.overlay}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
<X size={24} color="#ffffff" />
</Pressable>
<Text style={styles.title}>Scan QR code</Text>
<View style={styles.closeButton} />
</View>
{permission?.granted ? (
<View style={styles.frameWrap}>
<View style={styles.frame} />
<Text style={styles.hint}>
Point your camera at the QR code shown on the webmail settings screen.
</Text>
</View>
) : (
<View style={styles.permissionWrap}>
<Text style={styles.permissionText}>
{permission && !permission.canAskAgain
? 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.'
: 'Bulwark Mail needs camera access to scan a sign-in QR code.'}
</Text>
<Button variant="default" size="md" onPress={() => void requestPermission()}>
Allow camera access
</Button>
</View>
)}
</SafeAreaView>
</View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: '#000000' },
overlay: { flex: 1, justifyContent: 'flex-start' },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm,
paddingBottom: spacing.md,
},
closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
title: { ...typography.h3, color: '#ffffff' },
frameWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg },
frame: {
width: 240,
height: 240,
borderWidth: 3,
borderColor: '#ffffff',
borderRadius: radius.lg,
backgroundColor: 'transparent',
},
hint: {
...typography.caption,
color: '#ffffff',
textAlign: 'center',
paddingHorizontal: spacing.xxl,
},
permissionWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.lg,
paddingHorizontal: spacing.xxl,
backgroundColor: c.background,
},
permissionText: { ...typography.body, color: c.text, textAlign: 'center' },
});
}
+3 -273
View File
@@ -3,10 +3,7 @@ import { StyleSheet, View, Platform } from 'react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useColors } from '../theme/colors';
import type { ThemePalette } from '../theme/tokens';
// Minimum visible editor height (px). The editor auto-grows beyond this as the
// user types, and the parent ScrollView handles overflow.
const MIN_HEIGHT = 220;
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
export type RichTextCommand =
| 'bold'
@@ -66,273 +63,6 @@ interface Props {
onBlur?: () => void;
}
function buildEditorHtml(opts: {
initialHtml: string;
placeholder: string;
c: ThemePalette;
}): string {
const { initialHtml, placeholder, c } = opts;
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
// are safely embedded (no template literal collision with the script body).
const initialJson = JSON.stringify(initialHtml);
const placeholderJson = JSON.stringify(placeholder);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
overflow-y: hidden;
-webkit-text-size-adjust: 100%;
}
#editor {
outline: none;
padding: 12px 16px 24px;
min-height: ${MIN_HEIGHT}px;
word-wrap: break-word;
overflow-wrap: anywhere;
}
#editor[data-empty="true"]::before {
content: attr(data-placeholder);
color: ${c.textMuted};
pointer-events: none;
position: absolute;
}
#editor p { margin: 0 0 12px 0; }
#editor p:last-child { margin-bottom: 0; }
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
#editor li { margin-bottom: 4px; }
#editor blockquote {
margin: 0 0 12px 0;
padding: 4px 0 4px 12px;
border-left: 3px solid ${c.border};
color: ${c.textSecondary};
}
#editor a { color: ${c.primary}; text-decoration: underline; }
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
#editor pre, #editor code {
background: ${c.surfaceActive};
border-radius: 4px;
padding: 2px 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.92em;
}
#editor pre { padding: 8px 12px; overflow-x: auto; }
::selection { background: ${c.primary}33; }
</style>
</head>
<body>
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
<script>
(function () {
var editor = document.getElementById('editor');
var lastHtml = null;
var lastHeight = 0;
function refreshEmpty() {
var html = editor.innerHTML;
var isEmpty =
html === '' ||
html === '<br>' ||
html === '<p></p>' ||
html === '<p><br></p>' ||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
}
function post(type, payload) {
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
}
}
function reportHeight() {
var h = Math.max(${MIN_HEIGHT}, editor.scrollHeight + 8);
if (Math.abs(h - lastHeight) < 2) return;
lastHeight = h;
post('height', h);
}
function reportChange() {
refreshEmpty();
var html = editor.innerHTML;
if (html === lastHtml) return;
lastHtml = html;
post('change', html);
}
function reportSelection() {
function active(name) {
try { return document.queryCommandState(name); } catch (e) { return false; }
}
function inBlock(tag) {
var sel = window.getSelection();
if (!sel || !sel.rangeCount) return false;
var node = sel.anchorNode;
while (node && node !== editor) {
if (node.nodeType === 1 && node.tagName === tag) return true;
node = node.parentNode;
}
return false;
}
var state = {
bold: active('bold'),
italic: active('italic'),
underline: active('underline'),
strikeThrough: active('strikeThrough'),
ul: active('insertUnorderedList'),
ol: active('insertOrderedList'),
blockquote: inBlock('BLOCKQUOTE'),
h1: inBlock('H1'),
h2: inBlock('H2'),
alignLeft: active('justifyLeft'),
alignCenter: active('justifyCenter'),
alignRight: active('justifyRight'),
link: inBlock('A'),
};
post('selection', state);
}
// ── Initial content ────────────────────────────────────────────────
var initial = ${initialJson};
if (initial && typeof initial === 'string') editor.innerHTML = initial;
refreshEmpty();
lastHtml = editor.innerHTML;
editor.addEventListener('input', function () {
reportChange();
reportHeight();
});
editor.addEventListener('blur', function () { post('blur', null); });
editor.addEventListener('focus', function () { post('focus', null); });
document.addEventListener('selectionchange', function () {
if (document.activeElement === editor) reportSelection();
});
// ── Bridge: receive commands from RN ───────────────────────────────
window.__rne = {
exec: function (command, value) {
editor.focus();
try {
if (command === 'formatBlock' && value) {
// Toggle: if we're already in that block, switch to <p>.
var sel = window.getSelection();
if (sel && sel.rangeCount) {
var node = sel.anchorNode;
while (node && node !== editor) {
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
document.execCommand('formatBlock', false, '<p>');
reportChange();
reportHeight();
reportSelection();
return;
}
node = node.parentNode;
}
}
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
} else {
document.execCommand(command, false, value);
}
} catch (e) {}
reportChange();
reportHeight();
reportSelection();
},
setHtml: function (html) {
editor.innerHTML = html || '';
lastHtml = editor.innerHTML;
refreshEmpty();
reportChange();
reportHeight();
},
insertHtml: function (html) {
editor.focus();
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
insertLink: function (url, label) {
editor.focus();
// Reject anything that isn't a safe scheme or an absolute path, so the
// user can't accidentally (or via a malicious paste) ship a
// javascript:/data: link to their recipient.
var safeSchemeRe = /^(https?:|mailto:|tel:|sms:|\/|\?|#)/i;
var trimmed = (url || '').trim();
if (!trimmed || !safeSchemeRe.test(trimmed)) return;
var sel = window.getSelection();
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
if (hasSelection) {
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
// execCommand doesn't set rel; tag every anchor that points at this URL.
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
if (a.getAttribute('href') === trimmed) {
a.setAttribute('rel', 'noopener noreferrer nofollow');
}
});
} else {
var text = label && label.trim() ? label : trimmed;
var safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = trimmed
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
}
reportChange();
reportHeight();
reportSelection();
},
unsetLink: function () {
editor.focus();
try { document.execCommand('unlink', false, null); } catch (e) {}
reportChange();
reportSelection();
},
insertImage: function (src, cid, alt) {
editor.focus();
var safeSrc = (src || '').replace(/"/g, '&quot;');
var safeAlt = (alt || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
var safeCid = cid ? cid.replace(/"/g, '&quot;') : '';
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
focus: function () { editor.focus(); },
};
reportHeight();
reportChange();
// Re-measure after async layout (image loads, font swap).
window.addEventListener('load', function () {
setTimeout(reportHeight, 50);
setTimeout(reportHeight, 200);
setTimeout(reportHeight, 600);
});
if (window.ResizeObserver) {
new ResizeObserver(reportHeight).observe(editor);
}
})();
true;
</script>
</body>
</html>`;
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', onChange, onSelectionChange, onFocus, onBlur },
ref,
@@ -340,7 +70,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const webViewRef = React.useRef<WebView>(null);
const [height, setHeight] = React.useState(MIN_HEIGHT);
const [height, setHeight] = React.useState(MIN_EDITOR_HEIGHT);
const onChangeRef = React.useRef(onChange);
const onSelectionRef = React.useRef(onSelectionChange);
const onFocusRef = React.useRef(onFocus);
@@ -400,7 +130,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
break;
case 'height': {
const h = typeof data.payload === 'number' ? data.payload : Number(data.payload);
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_HEIGHT, h));
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_EDITOR_HEIGHT, h));
break;
}
case 'selection':
+2 -2
View File
@@ -172,9 +172,9 @@ export function SwipeableRow({
return;
}
// Instant mode: fire on release-past-threshold, no second tap.
if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') {
if ((g.dx >= COMMIT_THRESHOLD || (g.dx > 40 && g.vx >= 0.5)) && rightAction !== 'none') {
fly(widthRef.current || EXIT_DISTANCE, rightAction);
} else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') {
} else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && leftAction !== 'none') {
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
} else {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
+1
View File
@@ -83,6 +83,7 @@ function makeStyles(c: ThemePalette) {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
bar: {
flexDirection: 'row',
+32 -26
View File
@@ -1,15 +1,16 @@
import React from 'react';
import { SectionList, StyleSheet, Text, View } from 'react-native';
import { format, isSameDay, isToday, isTomorrow } from 'date-fns';
import { addDays, format, isSameDay, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns';
import { CalendarDays } from 'lucide-react-native';
import type { Calendar, CalendarEvent } from '../../api/types';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
buildEventDayIndex,
dayKey,
eventsOnDayFromIndex,
getEventStartDate,
type EventDayIndex,
type TimeFormat,
} from '../../lib/calendar-utils';
import { EventCard } from './EventCard';
@@ -19,6 +20,7 @@ interface AgendaViewProps {
events: CalendarEvent[];
calendars: Calendar[];
eventsByDay?: EventDayIndex;
timeFormat?: TimeFormat;
onSelectEvent?: (event: CalendarEvent) => void;
}
@@ -40,6 +42,7 @@ export function AgendaView({
events,
calendars,
eventsByDay,
timeFormat,
onSelectEvent,
}: AgendaViewProps) {
const c = useColors();
@@ -50,32 +53,35 @@ export function AgendaView({
);
const sections = React.useMemo<DaySection[]>(() => {
const result: DaySection[] = [];
const start = new Date(fromDate);
start.setHours(0, 0, 0, 0);
// Mirror webmail's agenda (calendar-agenda-view.tsx): build a group for
// every day that actually has events, always include a "Today" anchor, and
// render them sorted ascending. Driving the list off the events (rather than
// a fixed day-counter) guarantees every upcoming loaded event appears - not
// just the ones inside a brittle N-day window from the anchor.
const anchor = startOfDay(fromDate);
const horizonEnd = addDays(anchor, daysAhead); // safety bound (exclusive)
for (let i = 0; i < daysAhead; i++) {
const day = new Date(start);
day.setDate(start.getDate() + i);
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
// All-day events first within a day, then by start time, then title
// (stable tie-break) - mirrors the comparator the webmail uses for
// packing week segments and keeps multi-event days predictable.
if (a.showWithoutTime !== b.showWithoutTime) {
return a.showWithoutTime ? -1 : 1;
}
const diff = getEventStartDate(a).getTime() - getEventStartDate(b).getTime();
if (diff !== 0) return diff;
return (a.title || '').localeCompare(b.title || '');
});
if (dayEvents.length === 0 && !isToday(day)) continue;
result.push({
const days = new Map<string, Date>();
for (const key of index.keys()) {
const day = parseISO(key); // index keys are local yyyy-MM-dd
if (isNaN(day.getTime())) continue;
if (day < anchor || day >= horizonEnd) continue;
days.set(key, day);
}
// Always anchor "Today" so the view has a stable starting point even when
// the current day has no events (webmail injects the same anchor).
const today = startOfDay(new Date());
if (today >= anchor && today < horizonEnd) days.set(dayKey(today), today);
return [...days.values()]
.sort((a, b) => a.getTime() - b.getTime())
.map((day) => ({
title: formatDayHeader(day),
date: day,
data: dayEvents,
});
}
return result;
// Buckets are pre-sorted by buildEventDayIndex (all-day first, then by
// start, then title), so no extra sort is needed here.
data: eventsOnDayFromIndex(index, day),
}));
}, [fromDate, daysAhead, index]);
if (sections.length === 0) {
@@ -107,7 +113,7 @@ export function AgendaView({
}}
renderItem={({ item }) => (
<View style={styles.itemWrap}>
<EventCard event={item} calendars={calendars} onPress={onSelectEvent} />
<EventCard event={item} calendars={calendars} timeFormat={timeFormat} onPress={onSelectEvent} />
</View>
)}
renderSectionFooter={({ section }) =>
+159 -37
View File
@@ -11,12 +11,13 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Check, X, Upload, Rss } from 'lucide-react-native';
import { Check, X, Upload, Rss, Shuffle, Star } from 'lucide-react-native';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAnimDuration } from '../../theme/dynamic';
import { getCalendarColor } from '../../lib/calendar-utils';
import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils';
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
interface CalendarSidebarDrawerProps {
visible: boolean;
@@ -26,6 +27,11 @@ interface CalendarSidebarDrawerProps {
onClose: () => void;
onImport?: () => void;
onManageSubscriptions?: () => void;
// Long-press actions. Set-default applies to the user's own calendars;
// color change/reset applies to shared calendars (per-viewer recolor).
onSetDefault?: (calendar: Calendar) => void;
onSetColor?: (calendar: Calendar, color: string) => void;
onResetColor?: (calendar: Calendar) => void;
}
export function CalendarSidebarDrawer({
@@ -36,6 +42,9 @@ export function CalendarSidebarDrawer({
onClose,
onImport,
onManageSubscriptions,
onSetDefault,
onSetColor,
onResetColor,
}: CalendarSidebarDrawerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -43,6 +52,7 @@ export function CalendarSidebarDrawer({
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const openDuration = useAnimDuration(240);
const closeDuration = useAnimDuration(200);
const [expandedId, setExpandedId] = React.useState<string | null>(null);
React.useEffect(() => {
if (visible) {
@@ -60,6 +70,7 @@ export function CalendarSidebarDrawer({
}),
]).start();
} else {
setExpandedId(null);
Animated.parallel([
Animated.timing(slideX, {
toValue: -Dimensions.get('window').width,
@@ -78,8 +89,23 @@ export function CalendarSidebarDrawer({
const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]);
const myCalendars = calendars.filter((c) => !c.myRights || c.myRights.mayWrite !== false);
const subscribed = calendars.filter((c) => c.myRights && c.myRights.mayWrite === false);
const sharedCalendars = calendars.filter((cal) => cal.isShared);
const myCalendars = calendars.filter(
(cal) => !cal.isShared && (!cal.myRights || cal.myRights.mayWrite !== false),
);
const subscribed = calendars.filter(
(cal) => !cal.isShared && cal.myRights && cal.myRights.mayWrite === false,
);
const sectionProps = {
hiddenSet,
onToggle,
expandedId,
onExpand: setExpandedId,
onSetDefault,
onSetColor,
onResetColor,
};
return (
<Modal
@@ -110,21 +136,15 @@ export function CalendarSidebarDrawer({
)}
{myCalendars.length > 0 && (
<Section
title="My calendars"
calendars={myCalendars}
hiddenSet={hiddenSet}
onToggle={onToggle}
/>
<Section title="My calendars" calendars={myCalendars} {...sectionProps} />
)}
{sharedCalendars.length > 0 && (
<Section title="Shared with me" calendars={sharedCalendars} {...sectionProps} />
)}
{subscribed.length > 0 && (
<Section
title="Subscribed"
calendars={subscribed}
hiddenSet={hiddenSet}
onToggle={onToggle}
/>
<Section title="Subscribed" calendars={subscribed} {...sectionProps} />
)}
{(onImport || onManageSubscriptions) && (
@@ -161,11 +181,21 @@ function Section({
calendars,
hiddenSet,
onToggle,
expandedId,
onExpand,
onSetDefault,
onSetColor,
onResetColor,
}: {
title: string;
calendars: Calendar[];
hiddenSet: Set<string>;
onToggle: (id: string) => void;
expandedId: string | null;
onExpand: (id: string | null) => void;
onSetDefault?: (calendar: Calendar) => void;
onSetColor?: (calendar: Calendar, color: string) => void;
onResetColor?: (calendar: Calendar) => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -174,30 +204,85 @@ function Section({
<Text style={styles.sectionTitle}>{title}</Text>
{calendars.map((cal) => {
const visible = !hiddenSet.has(cal.id);
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
const canSetDefault = !!onSetDefault && !cal.isShared && !isBirthday && !cal.isDefault;
const canRecolor = !!onSetColor && !!cal.isShared;
const hasActions = canSetDefault || canRecolor;
const expanded = expandedId === cal.id && hasActions;
return (
<Pressable
key={cal.id}
onPress={() => onToggle(cal.id)}
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
]}
>
<View
style={[
styles.swatch,
{
backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
borderColor: getCalendarColor(cal),
},
<View key={cal.id}>
<Pressable
onPress={() => onToggle(cal.id)}
onLongPress={hasActions ? () => onExpand(expanded ? null : cal.id) : undefined}
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
]}
>
{visible && <Check size={12} color={c.textInverse} />}
</View>
<Text style={[styles.rowName, !visible && styles.rowNameMuted]}>
{cal.name}
</Text>
</Pressable>
<View
style={[
styles.swatch,
{
backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
borderColor: getCalendarColor(cal),
},
]}
>
{visible && <Check size={12} color={c.textInverse} />}
</View>
<Text style={[styles.rowName, !visible && styles.rowNameMuted]} numberOfLines={1}>
{cal.name}
</Text>
{cal.isDefault && !cal.isShared && (
<Star size={14} color={c.textMuted} fill={c.textMuted} />
)}
</Pressable>
{expanded && (
<View style={styles.actionsPanel}>
{canSetDefault && (
<Pressable
onPress={() => { onExpand(null); onSetDefault!(cal); }}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
>
<Star size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Set as default calendar</Text>
</Pressable>
)}
{canRecolor && (
<>
<View style={styles.paletteRow}>
{CALENDAR_COLOR_PALETTE.map((color) => {
const active = cal.color?.toLowerCase() === color.toLowerCase();
return (
<Pressable
key={color}
onPress={() => { onExpand(null); onSetColor!(cal, color); }}
style={[
styles.paletteSwatch,
{ backgroundColor: color },
active && styles.paletteSwatchActive,
]}
>
{active && <Check size={12} color={c.textInverse} />}
</Pressable>
);
})}
</View>
{!!onResetColor && cal.colorIsLocalOverride && (
<Pressable
onPress={() => { onExpand(null); onResetColor(cal); }}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
>
<Shuffle size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Random color</Text>
</Pressable>
)}
</>
)}
</View>
)}
</View>
);
})}
</View>
@@ -279,6 +364,43 @@ function makeStyles(c: ThemePalette) {
rowName: { flex: 1, ...typography.body, color: c.text },
rowNameMuted: { color: c.textMuted },
actionsPanel: {
marginHorizontal: spacing.lg,
marginBottom: spacing.sm,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
overflow: 'hidden',
},
panelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
minHeight: 40,
},
panelRowText: { ...typography.body, color: c.text },
paletteRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
paletteSwatch: {
width: 24,
height: 24,
borderRadius: radius.full,
alignItems: 'center',
justifyContent: 'center',
},
paletteSwatchActive: {
borderWidth: 2,
borderColor: c.text,
},
actionsSection: {
marginTop: spacing.lg,
paddingTop: spacing.sm,
+12 -5
View File
@@ -5,11 +5,17 @@ import { format } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { eventTimeRange, getEventColor } from '../../lib/calendar-utils';
import {
eventTimeRange,
getEventColor,
timePattern,
type TimeFormat,
} from '../../lib/calendar-utils';
interface EventCardProps {
event: CalendarEvent;
calendars: Calendar[];
timeFormat?: TimeFormat;
onPress?: (event: CalendarEvent) => void;
onLongPress?: (event: CalendarEvent) => void;
}
@@ -18,17 +24,18 @@ function participantCount(event: CalendarEvent): number {
return event.participants ? Object.keys(event.participants).length : 0;
}
function formatTimeRange(event: CalendarEvent): string {
function formatTimeRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
const { start, end, allDay } = eventTimeRange(event);
if (allDay) return 'All day';
return `${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
const fmt = timePattern(timeFormat);
return `${format(start, fmt)} – ${format(end, fmt)}`;
}
export function EventCard({ event, calendars, onPress, onLongPress }: EventCardProps) {
export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const color = getEventColor(event, calendars);
const time = formatTimeRange(event);
const time = formatTimeRange(event, timeFormat);
const count = participantCount(event);
return (
+9 -4
View File
@@ -36,6 +36,8 @@ import {
eventTimeRange,
getEventColor,
getPrimaryCalendarId,
timePattern,
type TimeFormat,
} from '../../lib/calendar-utils';
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
import {
@@ -50,6 +52,7 @@ type RsvpStatus = 'accepted' | 'declined' | 'tentative';
interface EventDetailSheetProps {
event: CalendarEvent | null;
calendars: Calendar[];
timeFormat?: TimeFormat;
onClose: () => void;
onEdit?: (event: CalendarEvent) => void;
onDelete?: (event: CalendarEvent) => void;
@@ -57,7 +60,7 @@ interface EventDetailSheetProps {
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
}
function formatRange(event: CalendarEvent): string {
function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
const { start, end, allDay } = eventTimeRange(event);
if (allDay) {
if (
@@ -69,14 +72,15 @@ function formatRange(event: CalendarEvent): string {
}
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
}
const t = timePattern(timeFormat);
const sameDay =
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate();
if (sameDay) {
return `${format(start, 'EEE, MMM d')} · ${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
return `${format(start, 'EEE, MMM d')} · ${format(start, t)} – ${format(end, t)}`;
}
return `${format(start, 'MMM d, HH:mm')} – ${format(end, 'MMM d, HH:mm')}`;
return `${format(start, `MMM d, ${t}`)} – ${format(end, `MMM d, ${t}`)}`;
}
function recurrenceLabel(event: CalendarEvent): string | null {
@@ -100,6 +104,7 @@ function recurrenceLabel(event: CalendarEvent): string | null {
export function EventDetailSheet({
event,
calendars,
timeFormat,
onClose,
onEdit,
onDelete,
@@ -156,7 +161,7 @@ export function EventDetailSheet({
const color = getEventColor(event, calendars);
const calendarId = getPrimaryCalendarId(event);
const calendar = calendars.find((c) => c.id === calendarId);
const range = formatRange(event);
const range = formatRange(event, timeFormat);
const recurrence = recurrenceLabel(event);
const reminders = alertsToReminders(event.alerts);
const participants = event.participants ? Object.values(event.participants) : [];
+69 -11
View File
@@ -31,6 +31,7 @@ import type {
CalendarEvent,
Participant,
EventLocation,
RecurrenceRule,
VirtualLocation,
} from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -49,10 +50,12 @@ import {
REMINDER_PRESETS,
type Reminder,
} from '../../lib/calendar-alerts';
import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence';
import { Button } from '..';
import { ParticipantInput } from './ParticipantInput';
import { RecurrenceEditor } from './RecurrenceEditor';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
{ value: 'none', label: 'Does not repeat' },
@@ -60,6 +63,7 @@ const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: 'Yearly' },
{ value: 'custom', label: 'Custom…' },
];
interface EventModalProps {
@@ -99,11 +103,15 @@ function buildDuration(start: Date, end: Date): string {
function detectRecurrence(event: CalendarEvent | null | undefined): RecurrenceOption {
const rule = event?.recurrenceRules?.[0];
if (!rule) return 'none';
const f = rule.frequency.toLowerCase();
if (f === 'daily' || f === 'weekly' || f === 'monthly' || f === 'yearly') {
return f as RecurrenceOption;
if (isSimpleRecurrenceRule(rule)) {
const f = rule.frequency.toLowerCase();
if (f === 'daily' || f === 'weekly' || f === 'monthly' || f === 'yearly') {
return f as RecurrenceOption;
}
}
return 'none';
// Anything the simple presets can't represent is edited via the custom
// recurrence editor.
return 'custom';
}
function detectLocation(event: CalendarEvent | null | undefined): string {
@@ -156,9 +164,18 @@ export function EventModal({
const [allDay, setAllDay] = React.useState(false);
const [start, setStart] = React.useState<Date>(() => nextHalfHour(defaultDate ?? new Date()));
const [end, setEnd] = React.useState<Date>(() => addHours(nextHalfHour(defaultDate ?? new Date()), 1));
const [calendarId, setCalendarId] = React.useState<string>(defaultCalendarId || calendars[0]?.id || '');
// New events land in the explicitly requested calendar, else the account's
// default calendar, else the first one.
const fallbackCalendarId =
defaultCalendarId
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|| calendars[0]?.id
|| '';
const [calendarId, setCalendarId] = React.useState<string>(fallbackCalendarId);
const [participants, setParticipants] = React.useState<Record<string, Participant>>({});
const [recurrence, setRecurrence] = React.useState<RecurrenceOption>('none');
const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
const [reminders, setReminders] = React.useState<Reminder[]>([]);
const [location, setLocation] = React.useState('');
const [videoUrl, setVideoUrl] = React.useState('');
@@ -181,7 +198,10 @@ export function EventModal({
setEnd(getEventEndDate(event));
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
setParticipants(event.participants || {});
setRecurrence(detectRecurrence(event));
const detected = detectRecurrence(event);
setRecurrence(detected);
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
setRecurrenceEditorOpen(false);
setReminders(alertsToReminders(event.alerts));
setLocation(detectLocation(event));
setVideoUrl(detectVideoUrl(event));
@@ -192,9 +212,16 @@ export function EventModal({
setAllDay(false);
setStart(d);
setEnd(addHours(d, 1));
setCalendarId(defaultCalendarId || calendars[0]?.id || '');
setCalendarId(
defaultCalendarId
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|| calendars[0]?.id
|| '',
);
setParticipants({});
setRecurrence('none');
setCustomRule(null);
setRecurrenceEditorOpen(false);
setReminders([]);
setLocation('');
setVideoUrl('');
@@ -241,7 +268,12 @@ export function EventModal({
: format(start, "yyyy-MM-dd'T'HH:mm:ss"),
duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end),
participants: Object.keys(participants).length > 0 ? participants : undefined,
recurrenceRules: recurrenceFromOption(recurrence),
recurrenceRules:
recurrence === 'custom'
? customRule
? [customRule]
: undefined
: recurrenceFromOption(recurrence),
alerts: remindersToAlerts(reminders),
useDefaultAlerts: reminders.length > 0 ? false : undefined,
locations: buildLocations(location),
@@ -259,7 +291,10 @@ export function EventModal({
};
const selectedCalendar = calendars.find((c) => c.id === calendarId);
const recurrenceLabel = RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None';
const recurrenceLabel =
recurrence === 'custom'
? (customRule && buildRecurrenceSummary(customRule)) || 'Custom'
: RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None';
const addReminder = (minutesBefore: number) => {
setReminderPickerOpen(false);
@@ -431,8 +466,14 @@ export function EventModal({
opt.value === recurrence && styles.popoverRowActive,
]}
onPress={() => {
setRecurrence(opt.value);
setRecurrenceOpen(false);
if (opt.value === 'custom') {
setRecurrenceEditorOpen(true);
return;
}
setRecurrence(opt.value);
setCustomRule(null);
setRecurrenceEditorOpen(false);
}}
>
<Text style={styles.popoverRowText}>{opt.label}</Text>
@@ -440,6 +481,23 @@ export function EventModal({
))}
</View>
)}
{recurrenceEditorOpen && (
<RecurrenceEditor
rule={customRule}
eventStart={start}
onSave={(rule) => {
setCustomRule(rule);
setRecurrence('custom');
setRecurrenceEditorOpen(false);
}}
onCancel={() => setRecurrenceEditorOpen(false)}
/>
)}
{recurrence === 'custom' && customRule && !recurrenceEditorOpen && (
<Pressable style={styles.addRow} onPress={() => setRecurrenceEditorOpen(true)}>
<Text style={styles.addRowText}>Edit custom recurrence</Text>
</Pressable>
)}
</Section>
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminders">
@@ -0,0 +1,370 @@
import React from 'react';
import { View, Text, TextInput, Pressable, StyleSheet, Platform } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { ChevronDown } from 'lucide-react-native';
import { format, parseISO } from 'date-fns';
import type { RecurrenceRule } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button } from '..';
import {
EDITOR_FREQUENCIES,
UNIT_LABELS,
WEEKDAYS,
buildRecurrenceSummary,
buildRuleFromEditorValue,
capitalize,
editorValueFromRule,
monthName,
nthLabel,
weekdayName,
type EditorFrequency,
type EndsMode,
type MonthlyMode,
type RecurrenceEditorValue,
} from '../../lib/recurrence';
interface RecurrenceEditorProps {
rule: RecurrenceRule | null;
eventStart: Date;
onSave: (rule: RecurrenceRule) => void;
onCancel: () => void;
}
// Small popover-style select, matching the EventModal field pattern.
function InlineSelect<T extends string | number>({
value,
options,
onChange,
flex,
}: {
value: T;
options: { value: T; label: string }[];
onChange: (value: T) => void;
flex?: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [open, setOpen] = React.useState(false);
const selected = options.find((o) => o.value === value);
return (
<View style={flex ? styles.selectFlex : undefined}>
<Pressable style={styles.selectButton} onPress={() => setOpen((v) => !v)}>
<Text style={styles.selectText} numberOfLines={1}>
{selected?.label ?? String(value)}
</Text>
<ChevronDown size={14} color={c.textMuted} />
</Pressable>
{open && (
<View style={styles.popover}>
{options.map((opt) => (
<Pressable
key={String(opt.value)}
style={[styles.popoverRow, opt.value === value && styles.popoverRowActive]}
onPress={() => {
onChange(opt.value);
setOpen(false);
}}
>
<Text style={styles.popoverRowText}>{opt.label}</Text>
</Pressable>
))}
</View>
)}
</View>
);
}
function NumberInput({
value,
min,
max,
onChange,
}: {
value: number;
min: number;
max: number;
onChange: (value: number) => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [text, setText] = React.useState(String(value));
React.useEffect(() => setText(String(value)), [value]);
return (
<TextInput
value={text}
onChangeText={setText}
onEndEditing={() => {
const n = parseInt(text, 10);
const clamped = Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : min;
setText(String(clamped));
onChange(clamped);
}}
keyboardType="number-pad"
style={styles.numberInput}
/>
);
}
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [value, setValue] = React.useState<RecurrenceEditorValue>(() =>
editorValueFromRule(rule, eventStart),
);
const [showUntilPicker, setShowUntilPicker] = React.useState(false);
const update = (patch: Partial<RecurrenceEditorValue>) =>
setValue((prev) => ({ ...prev, ...patch }));
const toggleWeekDay = (day: string) => {
setValue((prev) => ({
...prev,
weekDays: prev.weekDays.includes(day)
? prev.weekDays.length > 1
? prev.weekDays.filter((d) => d !== day)
: prev.weekDays
: [...prev.weekDays, day],
}));
};
const builtRule = buildRuleFromEditorValue(value, eventStart);
const summary = buildRecurrenceSummary(builtRule);
return (
<View style={styles.container}>
<View style={styles.row}>
<Text style={styles.rowLabel}>Every</Text>
<NumberInput
value={value.interval}
min={1}
max={999}
onChange={(interval) => update({ interval })}
/>
<InlineSelect<EditorFrequency>
flex
value={value.frequency}
options={EDITOR_FREQUENCIES.map((f) => ({ value: f, label: UNIT_LABELS[f] }))}
onChange={(frequency) => update({ frequency })}
/>
</View>
{value.frequency === 'weekly' && (
<View style={styles.weekdayRow}>
{WEEKDAYS.map((day) => {
const active = value.weekDays.includes(day);
return (
<Pressable
key={day}
onPress={() => toggleWeekDay(day)}
style={[styles.weekdayChip, active && styles.weekdayChipActive]}
>
<Text style={[styles.weekdayChipText, active && styles.weekdayChipTextActive]}>
{weekdayName(day, 'short')}
</Text>
</Pressable>
);
})}
</View>
)}
{value.frequency === 'yearly' && (
<View style={styles.row}>
<Text style={styles.rowLabel}>In</Text>
<InlineSelect<number>
flex
value={value.month}
options={Array.from({ length: 12 }, (_, i) => i + 1).map((m) => ({
value: m,
label: capitalize(monthName(m)),
}))}
onChange={(month) => update({ month })}
/>
</View>
)}
{(value.frequency === 'monthly' || value.frequency === 'yearly') && (
<View style={styles.row}>
<InlineSelect<MonthlyMode>
value={value.monthlyMode}
options={[
{ value: 'day', label: 'On day' },
{ value: 'nth', label: 'On the' },
]}
onChange={(monthlyMode) => update({ monthlyMode })}
/>
{value.monthlyMode === 'day' ? (
<NumberInput
value={value.monthDay}
min={1}
max={31}
onChange={(monthDay) => update({ monthDay })}
/>
) : (
<>
<InlineSelect<number>
flex
value={value.nth}
options={[1, 2, 3, 4, -1].map((n) => ({ value: n, label: capitalize(nthLabel(n)) }))}
onChange={(nth) => update({ nth })}
/>
<InlineSelect<string>
flex
value={value.nthDay}
options={WEEKDAYS.map((d) => ({ value: d, label: capitalize(weekdayName(d)) }))}
onChange={(nthDay) => update({ nthDay })}
/>
</>
)}
</View>
)}
<View style={styles.row}>
<Text style={styles.rowLabel}>Ends</Text>
<InlineSelect<EndsMode>
flex={value.endsMode === 'never'}
value={value.endsMode}
options={[
{ value: 'never', label: 'Never' },
{ value: 'on', label: 'On date' },
{ value: 'after', label: 'After' },
]}
onChange={(endsMode) => update({ endsMode })}
/>
{value.endsMode === 'on' && (
<Pressable style={[styles.selectButton, styles.selectFlex]} onPress={() => setShowUntilPicker(true)}>
<Text style={styles.selectText}>
{format(parseISO(value.untilDate), 'MMM d, yyyy')}
</Text>
</Pressable>
)}
{value.endsMode === 'after' && (
<>
<NumberInput
value={value.count}
min={1}
max={999}
onChange={(count) => update({ count })}
/>
<Text style={styles.muted}>occurrences</Text>
</>
)}
</View>
<View style={styles.footer}>
<Text style={styles.summary} numberOfLines={2}>
{summary}
</Text>
<View style={styles.footerButtons}>
<Button variant="outline" size="sm" onPress={onCancel}>
Cancel
</Button>
<Button variant="default" size="sm" onPress={() => onSave(builtRule)}>
Done
</Button>
</View>
</View>
{showUntilPicker && (
<DateTimePicker
value={parseISO(value.untilDate)}
mode="date"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(_, d) => {
setShowUntilPicker(false);
if (d) update({ untilDate: format(d, 'yyyy-MM-dd') });
}}
/>
)}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
padding: spacing.md,
gap: spacing.sm,
},
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
rowLabel: { ...typography.body, color: c.textSecondary },
muted: { ...typography.caption, color: c.textMuted, flexShrink: 1 },
numberInput: {
minWidth: 52,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.xs,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
color: c.text,
...typography.body,
textAlign: 'center',
},
selectFlex: { flex: 1, minWidth: 0 },
selectButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
minHeight: 36,
borderRadius: radius.xs,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
selectText: { ...typography.body, color: c.text, flexShrink: 1 },
popover: {
marginTop: spacing.xs,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.xs,
backgroundColor: c.background,
overflow: 'hidden',
},
popoverRow: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
popoverRowActive: { backgroundColor: c.primaryBg },
popoverRowText: { ...typography.body, color: c.text },
weekdayRow: { flexDirection: 'row', gap: 4 },
weekdayChip: {
flex: 1,
minWidth: 0,
paddingVertical: spacing.xs,
borderRadius: radius.xs,
borderWidth: 1,
borderColor: c.border,
alignItems: 'center',
},
weekdayChipActive: {
borderColor: c.primary,
backgroundColor: c.primaryBg,
},
weekdayChipText: { ...typography.caption, color: c.textMuted },
weekdayChipTextActive: { color: c.primary },
footer: {
borderTopWidth: 1,
borderTopColor: c.border,
paddingTop: spacing.sm,
gap: spacing.sm,
},
summary: { ...typography.caption, color: c.textMuted },
footerButtons: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm },
});
}
+425
View File
@@ -0,0 +1,425 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Folder, FileText, Trash2, UserPlus, Users } from 'lucide-react-native';
import { getPrincipals, isFolder, ownPrincipalId, setFileNodeShare } from '../../api/files';
import type { FileNode, FileNodeRights, Principal } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
type RolePreset = 'read' | 'readWrite' | 'manager';
// Same presets the webmail's share dialog offers (#408). "custom" rights set
// by another client are shown as-is and left untouched until the user picks a
// preset for that principal.
const FILE_PRESETS: Record<RolePreset, FileNodeRights> = {
read: {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
},
readWrite: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: false,
},
manager: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: true,
},
};
const PRESET_LABELS: Record<RolePreset, string> = {
read: 'Viewer',
readWrite: 'Editor',
manager: 'Manager',
};
const PRESET_ORDER: RolePreset[] = ['read', 'readWrite', 'manager'];
function detectPreset(rights: FileNodeRights): RolePreset | 'custom' {
for (const preset of PRESET_ORDER) {
const expected = FILE_PRESETS[preset];
const keys = Object.keys(expected) as (keyof FileNodeRights)[];
if (keys.every((k) => expected[k] === (rights[k] ?? false))) return preset;
}
return 'custom';
}
interface ShareSheetProps {
node: FileNode | null;
onClose: () => void;
/** Fired after any successful share change so the parent can refresh. */
onChanged: () => void;
}
export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [principals, setPrincipals] = useState<Principal[]>([]);
const [loadingPrincipals, setLoadingPrincipals] = useState(false);
const [shares, setShares] = useState<Record<string, FileNodeRights>>({});
const [search, setSearch] = useState('');
const [savingId, setSavingId] = useState<string | null>(null);
useEffect(() => {
if (!node) return;
setShares(node.shareWith ?? {});
setSearch('');
setLoadingPrincipals(true);
getPrincipals()
.then(setPrincipals)
.catch(() => setPrincipals([]))
.finally(() => setLoadingPrincipals(false));
}, [node]);
const principalsById = useMemo(() => {
const map = new Map<string, Principal>();
for (const p of principals) map.set(p.id, p);
return map;
}, [principals]);
const sharedEntries = useMemo(
() => Object.entries(shares).filter(([, rights]) => rights != null),
[shares],
);
const candidates = useMemo(() => {
const self = ownPrincipalId();
const q = search.trim().toLowerCase();
return principals
.filter((p) => p.id !== self && shares[p.id] == null)
.filter((p) =>
!q ||
p.name?.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q))
.slice(0, 25);
}, [principals, shares, search]);
const applyShare = useCallback(
async (principalId: string, rights: FileNodeRights | null) => {
if (!node || savingId) return;
setSavingId(principalId);
try {
await setFileNodeShare(node.id, principalId, rights);
setShares((prev) => {
const next = { ...prev };
if (rights == null) delete next[principalId];
else next[principalId] = rights;
return next;
});
onChanged();
} catch (e) {
Alert.alert('Sharing failed', e instanceof Error ? e.message : String(e));
} finally {
setSavingId(null);
}
},
[node, savingId, onChanged],
);
if (!node) return null;
const Icon = isFolder(node) ? Folder : FileText;
const renderPrincipalLabel = (principalId: string) => {
const p = principalsById.get(principalId);
return (
<View style={styles.principalInfo}>
<Text style={styles.principalName} numberOfLines={1}>
{p?.description || p?.name || principalId}
</Text>
{p?.email ? (
<Text style={styles.principalEmail} numberOfLines={1}>
{p.email}
</Text>
) : null}
</View>
);
};
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<View style={styles.titleRow}>
<Icon size={18} color={c.textMuted} />
<Text style={styles.title} numberOfLines={1}>
Share “{node.name}”
</Text>
</View>
{isFolder(node) ? (
<Text style={styles.subtitle}>
Sharing a folder shares everything inside it.
</Text>
) : null}
<ScrollView
style={styles.scroll}
keyboardShouldPersistTaps="handled"
>
{sharedEntries.length > 0 ? (
<View style={styles.section}>
<Text style={styles.sectionLabel}>Shared with</Text>
{sharedEntries.map(([principalId, rights]) => {
const preset = detectPreset(rights);
const busy = savingId === principalId;
return (
<View key={principalId} style={styles.shareRow}>
{renderPrincipalLabel(principalId)}
<View style={styles.roleChips}>
{PRESET_ORDER.map((p) => (
<Pressable
key={p}
onPress={() => void applyShare(principalId, FILE_PRESETS[p])}
disabled={busy}
style={[styles.chip, preset === p && styles.chipActive]}
>
<Text
style={[styles.chipText, preset === p && styles.chipTextActive]}
>
{PRESET_LABELS[p]}
</Text>
</Pressable>
))}
{preset === 'custom' ? (
<Text style={styles.customLabel}>Custom</Text>
) : null}
</View>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Pressable
onPress={() => void applyShare(principalId, null)}
hitSlop={8}
accessibilityLabel="Remove access"
>
<Trash2 size={18} color={c.error} />
</Pressable>
)}
</View>
);
})}
</View>
) : (
<View style={styles.emptyRow}>
<Users size={18} color={c.textMuted} />
<Text style={styles.emptyText}>Not shared with anyone yet</Text>
</View>
)}
<View style={styles.section}>
<Text style={styles.sectionLabel}>Add people</Text>
<TextInput
value={search}
onChangeText={setSearch}
placeholder="Search by name or email"
placeholderTextColor={c.textMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
/>
{loadingPrincipals ? (
<ActivityIndicator
size="small"
color={c.textMuted}
style={{ marginTop: spacing.sm }}
/>
) : candidates.length === 0 ? (
<Text style={styles.noMatches}>
{principals.length === 0 ? 'No other users found' : 'No matches'}
</Text>
) : (
candidates.map((p) => (
<Pressable
key={p.id}
onPress={() => void applyShare(p.id, FILE_PRESETS.read)}
disabled={savingId != null}
style={({ pressed }) => [
styles.candidateRow,
pressed && { backgroundColor: c.surfaceHover },
]}
>
{renderPrincipalLabel(p.id)}
{savingId === p.id ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<UserPlus size={18} color={c.primary} />
)}
</Pressable>
))
)}
</View>
</ScrollView>
<Pressable onPress={onClose} style={styles.doneBtn}>
<Text style={styles.doneText}>Done</Text>
</Pressable>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xxl,
paddingHorizontal: spacing.md,
maxHeight: '80%',
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
},
title: {
...typography.h3,
color: c.text,
flexShrink: 1,
},
subtitle: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
paddingHorizontal: spacing.sm,
marginTop: 2,
},
scroll: {
marginTop: spacing.sm,
},
section: {
marginTop: spacing.md,
},
sectionLabel: {
fontSize: typography.caption.fontSize,
fontWeight: '600' as const,
color: c.textMuted,
textTransform: 'uppercase' as const,
paddingHorizontal: spacing.sm,
marginBottom: spacing.xs,
},
shareRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
principalInfo: {
flex: 1,
minWidth: 0,
},
principalName: {
fontSize: typography.body.fontSize,
color: c.text,
},
principalEmail: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
},
roleChips: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
chip: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
},
chipActive: {
backgroundColor: c.primaryBg,
borderColor: c.primary,
},
chipText: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
},
chipTextActive: {
color: c.primary,
fontWeight: '600' as const,
},
customLabel: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
fontStyle: 'italic' as const,
},
emptyRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.md,
},
emptyText: {
fontSize: typography.body.fontSize,
color: c.textMuted,
},
input: {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: c.text,
fontSize: typography.body.fontSize,
backgroundColor: c.surface,
marginHorizontal: spacing.sm,
},
noMatches: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
},
candidateRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderRadius: radius.md,
},
doneBtn: {
marginTop: spacing.md,
alignSelf: 'stretch',
alignItems: 'center',
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: c.primary,
},
doneText: {
color: c.primaryForeground,
fontWeight: '600' as const,
fontSize: typography.body.fontSize,
},
});
}
+2
View File
@@ -5,3 +5,5 @@ export { default as Badge } from './Badge';
export { Card, SectionHeader, SettingItemRow } from './Card';
export { default as RadioGroup } from './RadioGroup';
export { default as Dialog } from './Dialog';
export { IdentitySheet } from './IdentitySheet';
+81 -3
View File
@@ -1,10 +1,18 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
import { SettingsSection, RadioGroup } from './settings-section';
import { ArrowRight, ArrowLeft, MoveVertical } from 'lucide-react-native';
import { SettingsSection, RadioGroup, Select } from './settings-section';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store';
import {
useSettingsStore,
normalizeBottomQuickActions,
REPLY_QUICK_ACTIONS,
ALL_QUICK_ACTIONS,
type SwipeAction,
type SwipeMode,
type QuickAction,
} from '../../stores/settings-store';
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'none', label: 'None' },
@@ -22,6 +30,24 @@ const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [
{ value: 'reveal', label: 'Reveal (swipe, then tap)' },
];
const QUICK_ACTION_LABELS: Record<QuickAction, string> = {
reply: 'Reply',
replyAll: 'Reply All',
forward: 'Forward',
delete: 'Delete',
archive: 'Archive',
markUnread: 'Mark Read/Unread',
star: 'Star',
move: 'Move to folder',
spam: 'Spam',
tag: 'Tag',
};
const QUICK_ACTION_OPTIONS = ALL_QUICK_ACTIONS.map((value) => ({
value,
label: QUICK_ACTION_LABELS[value],
}));
export function LayoutSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -31,11 +57,27 @@ export function LayoutSettings() {
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
const update = useSettingsStore((s) => s.updateSetting);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
const bottomActions = normalizeBottomQuickActions(bottomQuickActionsRaw);
// Pick an action for a bar slot. If the chosen action already occupies
// another slot, swap the two so the three slots stay unique.
const setQuickActionSlot = (index: number, value: QuickAction) => {
const next = [...bottomActions];
const existing = next.indexOf(value);
if (existing !== -1 && existing !== index) {
next[existing] = next[index];
}
next[index] = value;
update('bottomQuickActions', next);
};
const relocated = REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a));
return (
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
<View style={{ gap: spacing.sm }}>
@@ -81,6 +123,34 @@ export function LayoutSettings() {
/>
</View>
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<MoveVertical size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Reader quick actions</Text>
</View>
<Text style={styles.rowDescription}>
The three buttons shown between Prev/Next at the bottom of an open
email. Defaults to Reply, Reply All and Forward.
</Text>
{bottomActions.map((action, index) => (
<View key={index} style={styles.slotRow}>
<Text style={styles.slotLabel}>Slot {index + 1}</Text>
<Select
value={action}
onChange={(v) => setQuickActionSlot(index, v as QuickAction)}
options={QUICK_ACTION_OPTIONS}
/>
</View>
))}
{relocated.length > 0 && (
<Text style={styles.rowDescription}>
{relocated.map((a) => QUICK_ACTION_LABELS[a]).join(', ')}
{relocated.length === 1 ? ' is' : ' are'} moved to the top toolbar so
you can still use {relocated.length === 1 ? 'it' : 'them'}.
</Text>
)}
</View>
</SettingsSection>
);
}
@@ -90,5 +160,13 @@ function makeStyles(c: ThemePalette) {
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
rowLabel: { ...typography.bodyMedium, color: c.text },
rowDescription: { ...typography.caption, color: c.mutedForeground },
slotRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
marginTop: spacing.xs,
},
slotLabel: { ...typography.body, color: c.text },
});
}
+29
View File
@@ -7,6 +7,9 @@ import {
normalizeAllDayDuration,
buildAllDayDuration,
getCalendarColor,
getEventStartDate,
getEventEndDate,
timePattern,
layoutOverlappingEvents,
CALENDAR_COLOR_PALETTE,
} from '../calendar-utils';
@@ -51,6 +54,32 @@ describe('parseLocalDateTime', () => {
});
});
describe('getEventStartDate / getEventEndDate malformed utc fallback', () => {
// Mirrors webmail (#316): a malformed utcStart/utcEnd must fall back to the
// floating `start` + duration instead of producing an Invalid Date, which
// would silently drop the event from the day index and every view.
it('falls back to start when utcStart is unparseable', () => {
const d = getEventStartDate(ev({ utcStart: 'garbage' }));
expect(isNaN(d.getTime())).toBe(false);
expect(d.getHours()).toBe(9);
});
it('falls back to start + duration when utcEnd is unparseable', () => {
const end = getEventEndDate(ev({ utcEnd: 'garbage', duration: 'PT1H' }));
expect(isNaN(end.getTime())).toBe(false);
const start = getEventStartDate(ev({}));
expect(end.getTime() - start.getTime()).toBe(60 * 60 * 1000);
});
});
describe('timePattern', () => {
it('maps 12h to an am/pm pattern and 24h (or undefined) to HH:mm', () => {
expect(timePattern('12h')).toBe('h:mm a');
expect(timePattern('24h')).toBe('HH:mm');
expect(timePattern(undefined)).toBe('HH:mm');
});
});
describe('eventTimeRange', () => {
it('returns start, end, allDay for a timed event with utcStart/utcEnd', () => {
const range = eventTimeRange(ev({
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { buildEditorHtml } from '../editor-html';
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
function extractScript(html: string): string {
const m = html.match(/<script>([\s\S]*?)<\/script>/);
expect(m, 'page must contain an inline <script>').toBeTruthy();
return m![1];
}
describe('buildEditorHtml', () => {
const base = { initialHtml: '<p><br></p>', placeholder: 'Write…', c: LIGHT_COLORS };
// Regression for issue #11: backslash escapes inside the template literal
// are cooked away (`\/` → `/`), which once emitted an invalid regex. That
// SyntaxError killed the entire inline script, so the editor never posted
// `change` messages and Send stayed disabled on Android/iOS.
it('emits an inline script that parses as valid JavaScript', () => {
for (const c of [LIGHT_COLORS, DARK_COLORS]) {
const script = extractScript(buildEditorHtml({ ...base, c }));
// new Function() parses without executing (script needs a DOM to run).
expect(() => new Function(script)).not.toThrow();
}
});
it('emits no cooked-away backslashes in the script', () => {
// The page script is written escape-free on purpose; a backslash-free
// source cannot silently lose escapes to template-literal cooking.
const script = extractScript(buildEditorHtml(base));
expect(script).not.toContain('\\');
});
it('embeds initial content and placeholder as JSON strings', () => {
const html = buildEditorHtml({
...base,
initialHtml: '<p>He said "hi" & left</p>',
placeholder: 'Say "something"…',
});
expect(html).toContain(JSON.stringify('<p>He said "hi" & left</p>'));
expect(html).toContain(`data-placeholder=${JSON.stringify('Say "something"…')}`);
});
it('keeps the editor contenteditable and the bridge object wiring', () => {
const html = buildEditorHtml(base);
expect(html).toContain('<div id="editor" contenteditable="true"');
const script = extractScript(html);
expect(script).toContain('window.__rne');
expect(script).toContain('ReactNativeWebView.postMessage');
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockSecureFetch } = vi.hoisted(() => ({
mockSecureFetch: vi.fn(),
}));
vi.mock('../client-cert', () => ({
secureFetch: mockSecureFetch,
}));
import { refreshOAuthAccessToken, type OAuthTokens } from '../oauth';
describe('oauth token refresh deduplication', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should only send a single HTTP request when refreshOAuthAccessToken is called concurrently with the same refresh token', async () => {
const tokens: OAuthTokens = {
accessToken: 'old-access',
refreshToken: 'same-refresh-token',
expiresAt: 123456,
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'client-id-123',
};
// Set up a mock response that takes a brief moment to resolve
mockSecureFetch.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return {
ok: true,
status: 200,
json: async () => ({
access_token: 'new-access-token',
refresh_token: 'rotated-refresh-token',
expires_in: 3600,
}),
} as Response;
});
// Fire two refreshes concurrently
const [res1, res2] = await Promise.all([
refreshOAuthAccessToken(tokens),
refreshOAuthAccessToken(tokens),
]);
// Check that secureFetch was called exactly once
expect(mockSecureFetch).toHaveBeenCalledTimes(1);
// Verify both calls received the exact same updated token bundle
expect(res1.accessToken).toBe('new-access-token');
expect(res1.refreshToken).toBe('rotated-refresh-token');
expect(res2.accessToken).toBe('new-access-token');
expect(res2.refreshToken).toBe('rotated-refresh-token');
// Fire another refresh after the first completes (now the map should be empty for this token)
mockSecureFetch.mockClear();
const res3 = await refreshOAuthAccessToken(tokens);
expect(mockSecureFetch).toHaveBeenCalledTimes(1);
expect(res3.accessToken).toBe('new-access-token');
});
it('should propagate errors to all waiters if the HTTP request fails', async () => {
const tokens: OAuthTokens = {
accessToken: 'old-access',
refreshToken: 'failing-refresh-token',
expiresAt: 123456,
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'client-id-123',
};
mockSecureFetch.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return {
ok: false,
status: 400,
} as Response;
});
// Fire two refreshes concurrently and check that both reject with the same error
const promise1 = refreshOAuthAccessToken(tokens);
const promise2 = refreshOAuthAccessToken(tokens);
await expect(promise1).rejects.toThrow('Token refresh failed: 400');
await expect(promise2).rejects.toThrow('Token refresh failed: 400');
expect(mockSecureFetch).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Provide the Android native FCM surface setupPushNotifications needs. The
// global test-setup mocks react-native with an empty NativeModules, so override
// it here with a BulwarkFcm module and a pre-33 Platform.Version (which skips
// the runtime permission request).
vi.mock('react-native', () => {
class NativeEventEmitter {
addListener() {
return { remove: () => undefined };
}
}
return {
Platform: { OS: 'android', Version: 30, select: <T,>(s: { default?: T; android?: T }) => s.android ?? s.default },
NativeModules: {
BulwarkFcm: {
getToken: vi.fn(async () => 'fcm-token-xyz'),
deleteToken: vi.fn(async () => undefined),
},
},
NativeEventEmitter,
PermissionsAndroid: { RESULTS: { GRANTED: 'granted' }, request: vi.fn(async () => 'granted') },
};
});
vi.mock('../../api/jmap-client', () => ({
jmapClient: { username: 'user@example.com', serverUrl: 'https://mail.example.com' },
}));
vi.mock('../../api/push', () => ({
listPushSubscriptions: vi.fn(async () => []),
createPushSubscription: vi.fn(async () => 'new-server-id'),
verifyPushSubscription: vi.fn(async () => undefined),
destroyPushSubscription: vi.fn(async () => undefined),
updatePushSubscription: vi.fn(async () => true),
}));
import AsyncStorage from '@react-native-async-storage/async-storage';
import { setupPushNotifications, deviceClientIdKey } from '../push-notifications';
import {
listPushSubscriptions,
createPushSubscription,
destroyPushSubscription,
} from '../../api/push';
import { generateAccountId } from '../account-utils';
const OUR_DCID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const ACCOUNT_ID = generateAccountId('user@example.com', 'https://mail.example.com');
const RELAY = 'https://relay.example.com';
// State the fake relay reports for each foreign deviceClientId's /active probe.
type RelayState = 'dead' | 'live' | 'unknown';
function installFetch(states: Record<string, RelayState>): void {
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/api/push/register')) {
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
}
if (url.includes('/api/push/verify/')) {
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
}
const active = url.match(/\/api\/push\/active\/([^/?]+)$/);
if (active) {
const dcid = decodeURIComponent(active[1]);
const state = states[dcid] ?? 'unknown';
if (state === 'unknown') {
return { ok: false, status: 404, json: async () => ({ error: 'Unknown subscription' }) } as Response;
}
return { ok: true, status: 200, json: async () => ({ active: state === 'live' }) } as Response;
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
}
const destroyMock = destroyPushSubscription as ReturnType<typeof vi.fn>;
const listMock = listPushSubscriptions as ReturnType<typeof vi.fn>;
const createMock = createPushSubscription as ReturnType<typeof vi.fn>;
function sub(id: string, deviceClientId: string) {
return { id, deviceClientId, expires: new Date(Date.now() + 86400000).toISOString(), types: ['Email'] };
}
describe('setupPushNotifications leftover reaping', () => {
beforeEach(async () => {
vi.clearAllMocks();
await AsyncStorage.clear();
// Pin our deviceClientId so we control which leftovers are "ours".
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
});
it('reaps our own and relay-confirmed-dead leftovers, keeps live and unverifiable ones', async () => {
listMock.mockResolvedValue([
sub('own-old', OUR_DCID), // our own previous attempt -> reap
sub('foreign-dead', 'deaddeaddeaddeaddeaddeaddeaddead'), // relay: dead -> reap
sub('foreign-live', 'livelivelivelivelivelivelivelive'), // relay: live -> keep
sub('foreign-unknown', 'unknwunknwunknwunknwunknwunknwun'), // relay: 404 -> keep
]);
installFetch({
deaddeaddeaddeaddeaddeaddeaddead: 'dead',
livelivelivelivelivelivelivelive: 'live',
unknwunknwunknwunknwunknwunknwun: 'unknown',
});
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
expect(result.verified).toBe(true);
const reaped = destroyMock.mock.calls.map((c) => c[0]);
expect(reaped).toContain('own-old');
expect(reaped).toContain('foreign-dead');
expect(reaped).not.toContain('foreign-live');
expect(reaped).not.toContain('foreign-unknown');
});
it('keeps foreign subs when the relay probe fails (network error)', async () => {
listMock.mockResolvedValue([sub('foreign-x', 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')]);
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/api/push/register')) {
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
}
if (url.includes('/api/push/verify/')) {
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
}
if (url.includes('/api/push/active/')) throw new Error('network down');
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
await setupPushNotifications({ relayBaseUrl: RELAY });
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('foreign-x');
});
it('coalesces concurrent setups into a single flow (no subscription swarm)', async () => {
listMock.mockResolvedValue([]);
installFetch({});
// Fire several overlapping setups, as App.tsx does while auth settles and
// on FCM token refresh. Only one underlying JMAP subscription must be made.
const results = await Promise.all([
setupPushNotifications({ relayBaseUrl: RELAY }),
setupPushNotifications({ relayBaseUrl: RELAY }),
setupPushNotifications({ relayBaseUrl: RELAY }),
]);
expect(createMock).toHaveBeenCalledTimes(1);
expect(new Set(results.map((r) => r.subscriptionId)).size).toBe(1);
});
});
@@ -174,3 +174,77 @@ describe('expandRecurringEvents - range filtering', () => {
]);
});
});
describe('expandRecurringEvents - excludedRecurrenceRules', () => {
it('removes occurrences generated by excluded rules', () => {
const event = ev({
start: '2025-01-06T09:00:00',
recurrenceRules: [{ frequency: 'daily' }],
excludedRecurrenceRules: [{
frequency: 'weekly',
byDay: [{ day: 'tu' }],
}],
});
// Jan 6-12 2025: Mon-Sun, Tuesday Jan 7 excluded
const result = expandRecurringEvents([event], '2025-01-06T00:00:00', '2025-01-13T00:00:00');
const days = result.map((e) => e.start.substring(0, 10));
expect(days).not.toContain('2025-01-07');
expect(days).toContain('2025-01-06');
expect(days).toContain('2025-01-08');
expect(result).toHaveLength(6);
});
});
describe('expandRecurringEvents - long-running series', () => {
it('expands a daily series started years before the visible range', () => {
const event = ev({
start: '2022-01-03T09:00:00',
recurrenceRules: [{ frequency: 'daily' }],
});
const result = expandRecurringEvents([event], '2025-06-02T00:00:00', '2025-06-05T00:00:00');
expect(result.map((e) => e.start)).toEqual([
'2025-06-02T09:00:00',
'2025-06-03T09:00:00',
'2025-06-04T09:00:00',
]);
});
it('expands a weekly series started years before the visible range', () => {
const event = ev({
start: '2020-01-06T09:00:00', // Monday
recurrenceRules: [{ frequency: 'weekly' }],
});
const result = expandRecurringEvents([event], '2025-06-01T00:00:00', '2025-06-30T00:00:00');
const days = result.map((e) => e.start.substring(0, 10));
expect(days).toEqual(['2025-06-02', '2025-06-09', '2025-06-16', '2025-06-23']);
});
it('still respects count for old series (no fast-forward shortcut)', () => {
const event = ev({
start: '2025-01-06T09:00:00',
recurrenceRules: [{ frequency: 'daily', count: 5 }],
});
// Range far after the 5 occurrences ran out
const result = expandRecurringEvents([event], '2025-06-01T00:00:00', '2025-06-30T00:00:00');
expect(result).toHaveLength(0);
});
});
describe('expandRecurringEvents - event timezone DST handling', () => {
it('keeps wall time across a DST transition in the event timezone', () => {
// America/New_York: EST (UTC-5) until 2025-03-09, EDT (UTC-4) after
const event = ev({
start: '2025-03-03T10:00:00',
timeZone: 'America/New_York',
utcStart: '2025-03-03T15:00:00Z',
utcEnd: '2025-03-03T16:00:00Z',
recurrenceRules: [{ frequency: 'weekly' }],
});
const result = expandRecurringEvents([event], '2025-03-03T00:00:00', '2025-03-17T00:00:00');
const utcStarts = result.map((e) => e.utcStart);
expect(utcStarts[0]).toBe('2025-03-03T15:00:00.000Z'); // EST: 10:00 -5
expect(utcStarts[1]).toBe('2025-03-10T14:00:00.000Z'); // EDT: 10:00 -4
const utcEnds = result.map((e) => e.utcEnd);
expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z');
});
});
+73 -4
View File
@@ -35,13 +35,20 @@ export function parseLocalDateTime(iso: string): Date {
export function getEventStartDate(
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
): Date {
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
return parseISO(source);
// Prefer utcStart for timed events but fall back to start if utcStart is
// missing or unparseable — a malformed utcStart used to surface as an
// Invalid Date that silently dropped the event from every view (#316).
if (!event.showWithoutTime && event.utcStart) {
const utc = parseISO(event.utcStart);
if (!isNaN(utc.getTime())) return utc;
}
return parseISO(event.start);
}
export function getEventEndDate(event: CalendarEvent): Date {
if (!event.showWithoutTime && event.utcEnd) {
return parseISO(event.utcEnd);
const utc = parseISO(event.utcEnd);
if (!isNaN(utc.getTime())) return utc;
}
const start = getEventStartDate(event);
if (!event.duration) return start;
@@ -57,6 +64,17 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date {
return subMilliseconds(end, 1);
}
// ─── Time-of-day formatting ──────────────────────────────
// Maps the user's 12h/24h preference to a date-fns pattern, matching webmail
// (event-card.tsx: `timeFormat === "12h" ? "h:mm a" : "HH:mm"`). Components
// thread the `calendarTimeFormat` setting through so the calendar renders
// times in the same format the webmail does.
export type TimeFormat = '12h' | '24h';
export function timePattern(timeFormat: TimeFormat | undefined): string {
return timeFormat === '12h' ? 'h:mm a' : 'HH:mm';
}
export interface EventTimeRange {
start: Date;
end: Date;
@@ -435,12 +453,63 @@ export function getEventColor(
event: Pick<CalendarEvent, 'calendarIds' | 'color'>,
calendars: Calendar[],
): string {
if (event.color) return event.color;
const calendarId = Object.keys(event.calendarIds || {})[0];
const cal = calendars.find((c) => c.id === calendarId);
// A local color override on a shared calendar wins over per-event colors,
// so the whole shared calendar paints uniformly in the viewer's chosen hue.
if (cal?.colorIsLocalOverride && cal.color) return cal.color;
if (event.color) return event.color;
return getCalendarColor(cal);
}
// ─── Per-viewer colors for shared calendars (#345) ───────
// Shared calendars are recolored locally only: the viewer usually can't write
// the owner's calendar, and doing so would recolor it for everyone. Overrides
// live in the settings store keyed by sharedCalendarColorKey().
/**
* Stable key for a shared calendar's local color override. Built from the
* owning JMAP account + the calendar id so it stays unique across accounts.
*/
export function sharedCalendarColorKey(
cal: Pick<Calendar, 'id' | 'accountId'>,
): string {
return `${cal.accountId ?? ''}|${cal.id}`;
}
/**
* Pick a random palette color not present in `usedColors`. Once every palette
* entry is taken, fall back to a random palette color (collisions are
* unavoidable past CALENDAR_PALETTE.length calendars).
*/
export function pickUnusedCalendarColor(usedColors: Iterable<string>): string {
const used = new Set<string>();
for (const c of usedColors) {
if (c) used.add(c.toLowerCase());
}
const available = CALENDAR_PALETTE.filter((c) => !used.has(c.toLowerCase()));
const pool = available.length > 0 ? available : CALENDAR_PALETTE;
return pool[Math.floor(Math.random() * pool.length)];
}
/**
* Apply each shared calendar's local color override (per-viewer recolor).
* The override replaces the calendar's color and wins over per-event colors
* via the `colorIsLocalOverride` flag (see getEventColor). Personal calendars
* are passed through untouched.
*/
export function applySharedCalendarColors(
calendars: Calendar[],
overrides: Record<string, string>,
): Calendar[] {
return calendars.map((cal) => {
if (!cal.isShared) return cal;
const override = overrides[sharedCalendarColorKey(cal)];
if (!override) return cal;
return { ...cal, color: override, colorIsLocalOverride: true };
});
}
export function getPrimaryCalendarId(
event: Pick<CalendarEvent, 'calendarIds'>,
): string | undefined {
+286
View File
@@ -0,0 +1,286 @@
// Builds the self-contained HTML document for the RichTextEditor WebView.
// Kept as a pure module (no react-native imports) so tests can assert the
// embedded page script stays syntactically valid JavaScript.
//
// IMPORTANT: the page script lives inside a JS template literal. Backslash
// escapes are "cooked" by the template (`\/` becomes `/`, `\s` becomes `s`),
// which once shipped an invalid regex that killed the whole inline script
// with a SyntaxError (issue #11 — Send stayed disabled because the editor
// never posted `change` messages). Never rely on a backslash reaching the
// page verbatim; write escape-free code or double the backslash.
import type { ThemePalette } from '../theme/tokens';
// Minimum visible editor height (px). The editor auto-grows beyond this as the
// user types, and the parent ScrollView handles overflow.
export const MIN_EDITOR_HEIGHT = 220;
export function buildEditorHtml(opts: {
initialHtml: string;
placeholder: string;
c: ThemePalette;
}): string {
const { initialHtml, placeholder, c } = opts;
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
// are safely embedded (no template literal collision with the script body).
const initialJson = JSON.stringify(initialHtml);
const placeholderJson = JSON.stringify(placeholder);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
overflow-y: hidden;
-webkit-text-size-adjust: 100%;
}
#editor {
outline: none;
padding: 12px 16px 24px;
min-height: ${MIN_EDITOR_HEIGHT}px;
word-wrap: break-word;
overflow-wrap: anywhere;
}
#editor[data-empty="true"]::before {
content: attr(data-placeholder);
color: ${c.textMuted};
pointer-events: none;
position: absolute;
}
#editor p { margin: 0 0 12px 0; }
#editor p:last-child { margin-bottom: 0; }
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
#editor li { margin-bottom: 4px; }
#editor blockquote {
margin: 0 0 12px 0;
padding: 4px 0 4px 12px;
border-left: 3px solid ${c.border};
color: ${c.textSecondary};
}
#editor a { color: ${c.primary}; text-decoration: underline; }
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
#editor pre, #editor code {
background: ${c.surfaceActive};
border-radius: 4px;
padding: 2px 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.92em;
}
#editor pre { padding: 8px 12px; overflow-x: auto; }
::selection { background: ${c.primary}33; }
</style>
</head>
<body>
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
<script>
(function () {
var editor = document.getElementById('editor');
var lastHtml = null;
var lastHeight = 0;
function refreshEmpty() {
var html = editor.innerHTML;
var isEmpty =
html === '' ||
html === '<br>' ||
html === '<p></p>' ||
html === '<p><br></p>' ||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
}
function post(type, payload) {
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
}
}
function reportHeight() {
var h = Math.max(${MIN_EDITOR_HEIGHT}, editor.scrollHeight + 8);
if (Math.abs(h - lastHeight) < 2) return;
lastHeight = h;
post('height', h);
}
function reportChange() {
refreshEmpty();
var html = editor.innerHTML;
if (html === lastHtml) return;
lastHtml = html;
post('change', html);
}
function reportSelection() {
function active(name) {
try { return document.queryCommandState(name); } catch (e) { return false; }
}
function inBlock(tag) {
var sel = window.getSelection();
if (!sel || !sel.rangeCount) return false;
var node = sel.anchorNode;
while (node && node !== editor) {
if (node.nodeType === 1 && node.tagName === tag) return true;
node = node.parentNode;
}
return false;
}
var state = {
bold: active('bold'),
italic: active('italic'),
underline: active('underline'),
strikeThrough: active('strikeThrough'),
ul: active('insertUnorderedList'),
ol: active('insertOrderedList'),
blockquote: inBlock('BLOCKQUOTE'),
h1: inBlock('H1'),
h2: inBlock('H2'),
alignLeft: active('justifyLeft'),
alignCenter: active('justifyCenter'),
alignRight: active('justifyRight'),
link: inBlock('A'),
};
post('selection', state);
}
// ── Initial content ────────────────────────────────────────────────
var initial = ${initialJson};
if (initial && typeof initial === 'string') editor.innerHTML = initial;
refreshEmpty();
lastHtml = editor.innerHTML;
editor.addEventListener('input', function () {
reportChange();
reportHeight();
});
editor.addEventListener('blur', function () { post('blur', null); });
editor.addEventListener('focus', function () { post('focus', null); });
document.addEventListener('selectionchange', function () {
if (document.activeElement === editor) reportSelection();
});
// ── Bridge: receive commands from RN ───────────────────────────────
window.__rne = {
exec: function (command, value) {
editor.focus();
try {
if (command === 'formatBlock' && value) {
// Toggle: if we're already in that block, switch to <p>.
var sel = window.getSelection();
if (sel && sel.rangeCount) {
var node = sel.anchorNode;
while (node && node !== editor) {
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
document.execCommand('formatBlock', false, '<p>');
reportChange();
reportHeight();
reportSelection();
return;
}
node = node.parentNode;
}
}
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
} else {
document.execCommand(command, false, value);
}
} catch (e) {}
reportChange();
reportHeight();
reportSelection();
},
setHtml: function (html) {
editor.innerHTML = html || '';
lastHtml = editor.innerHTML;
refreshEmpty();
reportChange();
reportHeight();
},
insertHtml: function (html) {
editor.focus();
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
insertLink: function (url, label) {
editor.focus();
// Reject anything that isn't a safe scheme or an absolute path, so the
// user can't accidentally (or via a malicious paste) ship a
// javascript:/data: link to their recipient. Written without regex
// backslash escapes — see the template-cooking warning at the top.
var trimmed = (url || '').trim();
var first = trimmed.charAt(0);
var safeScheme = /^(https?:|mailto:|tel:|sms:)/i.test(trimmed)
|| first === '/' || first === '?' || first === '#';
if (!trimmed || !safeScheme) return;
var sel = window.getSelection();
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
if (hasSelection) {
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
// execCommand doesn't set rel; tag every anchor that points at this URL.
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
if (a.getAttribute('href') === trimmed) {
a.setAttribute('rel', 'noopener noreferrer nofollow');
}
});
} else {
var text = label && label.trim() ? label : trimmed;
var safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = trimmed
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
}
reportChange();
reportHeight();
reportSelection();
},
unsetLink: function () {
editor.focus();
try { document.execCommand('unlink', false, null); } catch (e) {}
reportChange();
reportSelection();
},
insertImage: function (src, cid, alt) {
editor.focus();
var safeSrc = (src || '').replace(/"/g, '&quot;');
var safeAlt = (alt || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
var safeCid = cid ? cid.replace(/"/g, '&quot;') : '';
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
focus: function () { editor.focus(); },
};
reportHeight();
reportChange();
// Re-measure after async layout (image loads, font swap).
window.addEventListener('load', function () {
setTimeout(reportHeight, 50);
setTimeout(reportHeight, 200);
setTimeout(reportHeight, 600);
});
if (window.ResizeObserver) {
new ResizeObserver(reportHeight).observe(editor);
}
})();
true;
</script>
</body>
</html>`;
}
+6 -2
View File
@@ -89,13 +89,15 @@ export async function shareAttachment(
name: string | undefined,
type: string | undefined,
email?: Email | null,
// Owning account for blobs shared by another principal (Files app).
accountId?: string,
): Promise<void> {
const filename = email
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
: safeAttachmentName(name, type);
const mimeType = type || 'application/octet-stream';
const dest = new File(Paths.cache, filename);
const url = getDownloadUrl(blobId, filename, mimeType);
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
const downloaded = await downloadInto(url, dest, Paths.cache);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
@@ -116,13 +118,15 @@ export async function downloadAttachment(
name: string | undefined,
type: string | undefined,
email?: Email | null,
// Owning account for blobs shared by another principal (Files app).
accountId?: string,
): Promise<void> {
const filename = email
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
: safeAttachmentName(name, type);
const mimeType = type || 'application/octet-stream';
const dest = new File(Paths.document, filename);
const url = getDownloadUrl(blobId, filename, mimeType);
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
const downloaded = await downloadInto(url, dest, Paths.document);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
Binary file not shown.
+33
View File
@@ -96,3 +96,36 @@ export function flattenVisible(
walk(nodes);
return out;
}
export function findTrashMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
const roleMatch = mailboxes.find((m) => m.role === 'trash');
if (roleMatch) return roleMatch;
const names = ['trash', 'bin', 'deleted', 'deleted items', 'corbeille', 'messages supprimés', 'éléments supprimés', 'supprimé', 'supprimés'];
return mailboxes.find((m) => {
const lower = m.name.toLowerCase();
return names.includes(lower) || names.some((n) => lower.includes(n));
});
}
export function findArchiveMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
const roleMatch = mailboxes.find((m) => m.role === 'archive');
if (roleMatch) return roleMatch;
const names = ['archive', 'archives', 'archived'];
return mailboxes.find((m) => {
const lower = m.name.toLowerCase();
return names.includes(lower) || names.some((n) => lower.includes(n));
});
}
export function findJunkMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
const roleMatch = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
if (roleMatch) return roleMatch;
const names = ['junk', 'spam', 'indésirables', 'indésirable', 'courrier indésirable'];
return mailboxes.find((m) => {
const lower = m.name.toLowerCase();
return names.includes(lower) || names.some((n) => lower.includes(n));
});
}
+131 -30
View File
@@ -123,6 +123,92 @@ export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResu
throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`);
}
// QR login payloads. A QR scanned on the login screen either bootstraps the
// server URL for the normal webmail handoff (`connect`), or carries a one-time
// cross-device pairing code minted by an already-signed-in webmail (`pair`).
// The payload never contains credentials — the `pair` code is redeemed for
// tokens over the network, once.
export type QrLoginPayload =
| { kind: 'connect'; webmailUrl: string }
| { kind: 'pair'; webmailUrl: string; code: string };
export function parseQrLoginPayload(raw: string): QrLoginPayload | null {
const trimmed = raw.trim();
// Custom scheme: bulwarkmail://connect?server=... | bulwarkmail://pair?server=...&code=...
const match = /^bulwarkmail:\/\/(connect|pair)\?(.*)$/i.exec(trimmed);
if (match) {
const kind = match[1].toLowerCase();
const params = new URLSearchParams(match[2]);
const server = params.get('server');
if (!server || !/^https?:\/\//i.test(server)) return null;
if (kind === 'pair') {
const code = params.get('code');
if (!code) return null;
return { kind: 'pair', webmailUrl: server, code };
}
return { kind: 'connect', webmailUrl: server };
}
// A bare https URL is treated as a server-bootstrap target so admins can
// hand out a plain webmail URL QR without the custom-scheme wrapper.
if (/^https?:\/\//i.test(trimmed)) {
return { kind: 'connect', webmailUrl: trimmed };
}
return null;
}
// Cross-device pairing redemption. Posts the scanned code to the webmail that
// minted it and maps the token bundle into the same shape the in-browser OAuth
// handoff produces, so the caller can reuse the existing connectWithOAuth path.
export async function redeemPairingCode(webmailUrl: string, code: string): Promise<HandoffResult> {
const base = webmailUrl.replace(/\/+$/, '');
let response: Response;
try {
response = await secureFetch(`${base}/api/auth/pair/redeem`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ pairing_code: code }),
});
} catch {
throw new HandoffError('Could not reach the server to complete pairing');
}
if (!response.ok) {
// The code was unknown, already used, or expired (server returns 400) —
// or something else went wrong. Either way the user needs a fresh QR.
throw new HandoffError('Pairing code is invalid or has expired');
}
const data = (await response.json()) as {
server_url?: string;
access_token?: string;
refresh_token?: string;
expires_in?: number;
token_endpoint?: string;
client_id?: string;
};
if (!data.server_url || !data.access_token || !data.token_endpoint || !data.client_id) {
throw new HandoffError('Pairing response missing token material');
}
return {
flow: 'oauth',
serverUrl: data.server_url,
tokens: {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? undefined,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
tokenEndpoint: data.token_endpoint,
clientId: data.client_id,
},
};
}
const activeRefreshes = new Map<string, Promise<OAuthTokens>>();
// OAuth refresh — exchanges the refresh token at the original token endpoint
// for a new access token. Returns the updated bundle so the caller can
// persist it.
@@ -130,37 +216,52 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
if (!tokens.refreshToken) {
throw new HandoffError('No refresh token available');
}
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refreshToken,
client_id: tokens.clientId,
});
const response = await secureFetch(tokens.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
throw new HandoffError(`Token refresh failed: ${response.status}`);
const cacheKey = tokens.refreshToken;
let promise = activeRefreshes.get(cacheKey);
if (!promise) {
promise = (async () => {
try {
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refreshToken!,
client_id: tokens.clientId,
});
const response = await secureFetch(tokens.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
throw new HandoffError(`Token refresh failed: ${response.status}`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!data.access_token) {
throw new HandoffError('Token refresh response missing access_token');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? tokens.refreshToken!,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
tokenEndpoint: tokens.tokenEndpoint,
clientId: tokens.clientId,
};
} finally {
activeRefreshes.delete(cacheKey);
}
})();
activeRefreshes.set(cacheKey, promise);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!data.access_token) {
throw new HandoffError('Token refresh response missing access_token');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? tokens.refreshToken,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
tokenEndpoint: tokens.tokenEndpoint,
clientId: tokens.clientId,
};
return promise;
}
WebBrowser.maybeCompleteAuthSession();
+76 -15
View File
@@ -217,6 +217,33 @@ async function registerWithRelay(params: {
}
}
/**
* Ask the relay whether a leftover subscription is dead. Returns true ONLY when
* the relay positively reports it inactive - a record it knows about that has
* never forwarded a push and isn't freshly registered. Every other outcome (the
* relay doesn't recognise the id, an older relay without this endpoint, a
* network blip, or a live subscription) returns false, so we never reap
* anything we can't confirm is dead. This is what lets setup clear its own
* abandoned attempts - and dead siblings left by reinstalls that regenerated
* the deviceClientId - without disturbing another live device or the PWA that
* shares the account.
*/
async function relayReportsDead(
relayBaseUrl: string,
subscriptionId: string,
): Promise<boolean> {
try {
const res = await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`),
);
if (!res.ok) return false;
const body = (await res.json()) as { active?: unknown };
return body.active === false;
} catch {
return false;
}
}
async function pollVerificationCode(
relayBaseUrl: string,
subscriptionId: string,
@@ -241,12 +268,36 @@ async function pollVerificationCode(
throw new Error('Timed out waiting for PushVerification from JMAP server');
}
// Coalesce concurrent setup attempts per account. App.tsx re-runs its push
// effect as auth state settles during startup and again on every FCM token
// refresh; because a failing attempt blocks for up to 75s polling for the
// verification code, those re-fires overlap. Without this guard each
// overlapping run mints its OWN deviceClientId and JMAP subscription, and the
// resulting swarm starves Stalwart's one-PushVerification-per-60s slot so none
// of them ever verifies (the symptom is a perpetual "Timed out waiting for
// PushVerification"). Callers share the first in-flight run instead.
const inFlightSetups = new Map<string, Promise<PushSetupResult>>();
/**
* Full setup flow: ask permission, fetch the device's FCM token, register
* with the relay, create a JMAP PushSubscription, poll for the verification
* code, and finalise the subscription.
* code, and finalise the subscription. Concurrent calls for the same account
* are coalesced onto a single in-flight run.
*/
export async function setupPushNotifications(
export function setupPushNotifications(
params: PushSetupParams,
): Promise<PushSetupResult> {
const key = `${jmapClient.username ?? ''}@${jmapClient.serverUrl ?? ''}`;
const existing = inFlightSetups.get(key);
if (existing) return existing;
const run = setupPushNotificationsInner(params).finally(() => {
inFlightSetups.delete(key);
});
inFlightSetups.set(key, run);
return run;
}
async function setupPushNotificationsInner(
params: PushSetupParams,
): Promise<PushSetupResult> {
const native = getNative();
@@ -306,19 +357,29 @@ export async function setupPushNotifications(
await AsyncStorage.removeItem(subKey);
}
// Reap any leftover Stalwart subscriptions still bound to this account's
// deviceClientId. These pile up when a previous enable attempt failed
// mid-flow (verify race, network blip, app killed). Stalwart per-account
// rate-limits PushVerification posts, so leaving stragglers around blocks
// the new one's verification - the symptom is a confusing "code does not
// match". Only reap matches for THIS account's deviceClientId; other
// accounts' subscriptions on this server (rare but possible if the same
// JMAP server backs multiple accounts) must be left alone.
const stragglers = existingSubs.filter(
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
);
for (const s of stragglers) {
await destroyPushSubscription(s.id).catch(() => undefined);
// Reap leftover Stalwart subscriptions that would otherwise starve the new
// one's verification. Stalwart emits only one PushVerification per account
// per ~60s and picks the oldest unverified subscription, so a single stale
// straggler blocks every fresh attempt - the symptom is the confusing
// "Timed out waiting for PushVerification" error. We can't read a
// subscription's verified state or URL over JMAP (Stalwart hides both), only
// its deviceClientId, so we decide what's safe to remove like this:
// - same deviceClientId as ours: a previous attempt from THIS device,
// always safe to reap.
// - a different deviceClientId: could be another live device or the PWA on
// this account. Ask the relay whether it's still alive and only reap the
// ones it confirms are dead. Anything live - or anything the relay can't
// vouch for (a different relay, a non-Bulwark client, a network blip) -
// is left untouched.
for (const s of existingSubs) {
if (s.id === storedServerId) continue;
if (s.deviceClientId === deviceClientId) {
await destroyPushSubscription(s.id).catch(() => undefined);
continue;
}
if (await relayReportsDead(relayBaseUrl, s.deviceClientId)) {
await destroyPushSubscription(s.id).catch(() => undefined);
}
}
const serverAssignedId = await createPushSubscription({
+191 -27
View File
@@ -11,7 +11,7 @@
* on the client.
*/
import { addDays, addMonths, addWeeks, addYears, format, parseISO } from 'date-fns';
import { addDays, addMonths, addWeeks, addYears, differenceInCalendarDays, format, parseISO } from 'date-fns';
import type { CalendarEvent, RecurrenceRule } from '../api/types';
type NDay = { day: string; nthOfPeriod?: number };
@@ -60,6 +60,10 @@ export function expandRecurringEvents(
return result;
}
function occurrenceDateKey(master: CalendarEvent, date: Date): string {
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
}
function expandEvent(
master: CalendarEvent,
rangeStart: Date,
@@ -73,13 +77,23 @@ function expandEvent(
const occurrences: CalendarEvent[] = [];
const seenDates = new Set<string>();
// RFC 8984 §4.3.3: occurrences produced by excludedRecurrenceRules
// (iCalendar EXRULE) are removed from the recurrence set.
const excludedDates = new Set<string>();
for (const exRule of master.excludedRecurrenceRules || []) {
// includeStartDate=false: "the series start is always an occurrence"
// applies to recurrence rules, not to exclusion rules.
for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false)) {
excludedDates.add(occurrenceDateKey(master, date));
}
}
for (const rule of rules) {
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
for (const date of dates) {
const dateKey = master.showWithoutTime
? format(date, 'yyyy-MM-dd')
: date.toISOString();
const dateKey = occurrenceDateKey(master, date);
if (excludedDates.has(dateKey)) continue;
if (seenDates.has(dateKey)) continue;
seenDates.add(dateKey);
@@ -104,9 +118,9 @@ function expandEvent(
if (isNaN(overrideDate.getTime())) continue;
if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue;
const dateKey = master.showWithoutTime
? format(overrideDate, 'yyyy-MM-dd')
: overrideDate.toISOString();
// Overrides take precedence over excludedRecurrenceRules (they re-add
// a concrete instance), so only dedupe against already-generated dates.
const dateKey = occurrenceDateKey(master, overrideDate);
if (seenDates.has(dateKey)) continue;
seenDates.add(dateKey);
@@ -116,6 +130,65 @@ function expandEvent(
return occurrences;
}
// Cache Intl formatters per IANA timezone id — constructing them is expensive
// and expansion runs over hundreds of occurrences. `null` marks ids the
// runtime rejected so we don't retry them.
const tzFormatterCache = new Map<string, Intl.DateTimeFormat | null>();
function getTzFormatter(timeZone: string): Intl.DateTimeFormat | null {
let formatter = tzFormatterCache.get(timeZone);
if (formatter === undefined) {
try {
formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
} catch {
formatter = null;
}
tzFormatterCache.set(timeZone, formatter);
}
return formatter;
}
/** The wall-clock reading of `instant` in the formatter's zone, re-encoded as a UTC timestamp. */
function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number): number {
const map: Record<string, string> = {};
for (const part of formatter.formatToParts(new Date(instant))) {
if (part.type !== 'literal') map[part.type] = part.value;
}
const hour = map.hour === '24' ? 0 : Number(map.hour);
return Date.UTC(
Number(map.year), Number(map.month) - 1, Number(map.day),
hour, Number(map.minute), Number(map.second),
);
}
/**
* Interpret the wall-clock fields of `wall` as a local time in `timeZone`
* and return the corresponding UTC instant. Two fixup iterations converge
* for all real offsets, including across DST transitions.
*/
function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
const formatter = getTzFormatter(timeZone);
if (!formatter) return null;
const wallAsUtc = Date.UTC(
wall.getFullYear(), wall.getMonth(), wall.getDate(),
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
);
let guess = wallAsUtc;
for (let i = 0; i < 2; i++) {
guess = wallAsUtc - (wallClockAsUtcTimestamp(formatter, guess) - guess);
}
return new Date(guess);
}
function createOccurrence(
master: CalendarEvent,
date: Date,
@@ -130,16 +203,33 @@ function createOccurrence(
// getEventEndDate return the occurrence times rather than the master's.
let utcStart: string | undefined;
let utcEnd: string | undefined;
if (!master.showWithoutTime && master.utcStart && master.start) {
const masterLocal = parseISO(master.start);
const masterUtc = parseISO(master.utcStart);
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
utcStart = new Date(date.getTime() + offsetMs).toISOString();
if (!master.showWithoutTime) {
let durationMs: number | null = null;
if (master.utcStart && master.utcEnd) {
const ms = parseISO(master.utcEnd).getTime() - parseISO(master.utcStart).getTime();
if (!isNaN(ms)) durationMs = ms;
}
if (master.utcEnd) {
const masterUtcEnd = parseISO(master.utcEnd);
const durationMs = masterUtcEnd.getTime() - masterUtc.getTime();
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
// Convert the occurrence's wall time using the event's own timezone, so
// occurrences on the other side of a DST transition in that zone keep
// their wall time. Reusing the master's fixed UTC offset (the fallback
// below) would shift them by the DST delta.
const zoned = master.timeZone ? zonedWallTimeToUtc(date, master.timeZone) : null;
if (zoned) {
utcStart = zoned.toISOString();
if (durationMs !== null) {
utcEnd = new Date(zoned.getTime() + durationMs).toISOString();
}
} else if (master.utcStart && master.start) {
// Floating events (or an unrecognized timezone id): keep the master's
// offset, which by definition doesn't vary.
const masterLocal = parseISO(master.start);
const masterUtc = parseISO(master.utcStart);
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
utcStart = new Date(date.getTime() + offsetMs).toISOString();
if (durationMs !== null) {
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
}
}
}
@@ -207,6 +297,7 @@ function generateDates(
rawRule: RecurrenceRule,
rangeStart: Date,
rangeEnd: Date,
includeStartDate = true,
): Date[] {
const rule = addImplicitByX(rawRule, eventStart);
const dates: Date[] = [];
@@ -214,21 +305,34 @@ function generateDates(
const countLimit = rule.count || Infinity;
const until = rule.until ? parseISO(rule.until) : null;
let totalCount = 0;
let current = new Date(eventStart);
// Without a `count` limit we can jump straight to the period containing
// the visible range. With one, every occurrence since the series start
// must be generated so it counts against `count`.
let current = rule.count
? new Date(eventStart)
: fastForwardToRange(eventStart, rule.frequency, interval, rangeStart);
const maxIterations = 2000;
// Cap on *emitted* (in-range) dates. This must not count occurrences
// before rangeStart, otherwise a series started long ago (e.g. a daily
// event from two years back) exhausts the budget before reaching the
// visible range and silently renders nothing.
const maxOccurrences = 500;
let iterations = 0;
while (iterations++ < maxIterations) {
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
if (until && current > until) break;
if (
current >= rangeEnd
&& rule.frequency !== 'yearly'
&& rule.frequency !== 'monthly'
&& rule.frequency !== 'weekly'
) break;
// Stop once no candidate in this or a later period can fall before
// rangeEnd. Monthly/yearly candidates may precede the period anchor
// within the same month/year, so floor the anchor before comparing.
if (rule.frequency === 'monthly') {
if (new Date(current.getFullYear(), current.getMonth(), 1) >= rangeEnd) break;
} else if (rule.frequency === 'yearly') {
if (new Date(current.getFullYear(), 0, 1) >= rangeEnd) break;
} else if (current >= rangeEnd) {
break;
}
const candidates = generateCandidatesForPeriod(current, rule, eventStart);
@@ -238,7 +342,7 @@ function generateDates(
for (const d of filtered) {
if (until && d > until) break;
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
// Spec rule 4: eliminate dates before event start
if (d < eventStart) continue;
@@ -250,7 +354,7 @@ function generateDates(
if (d >= rangeEnd) break;
}
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
current = advancePeriod(current, rule.frequency, interval);
if (current <= eventStart && iterations === 1) {
@@ -259,7 +363,7 @@ function generateDates(
}
// Spec rule 1: the start date-time is ALWAYS the first occurrence.
if (dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
if (includeStartDate && dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
if (eventStart >= rangeStart && eventStart < rangeEnd) {
if (!dates.some((d) => d.getTime() === eventStart.getTime())) {
dates.unshift(eventStart);
@@ -505,6 +609,66 @@ function advancePeriod(
}
}
// ─── Fast-forward to the visible range ───────────────────
// Jump to the period just before the visible range in O(1), so long-running
// series don't burn the iteration budget on years of out-of-range periods.
// Only valid for rules without `count` (counted rules must enumerate every
// occurrence from the series start).
function fastForwardToRange(
eventStart: Date,
frequency: RecurrenceRule['frequency'],
interval: number,
rangeStart: Date,
): Date {
if (eventStart >= rangeStart) return new Date(eventStart);
let periods: number;
switch (frequency) {
case 'secondly':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (1000 * interval));
break;
case 'minutely':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (60000 * interval));
break;
case 'hourly':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (3600000 * interval));
break;
case 'daily':
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / interval);
break;
case 'weekly':
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / (7 * interval));
break;
case 'monthly': {
const months = (rangeStart.getFullYear() - eventStart.getFullYear()) * 12
+ (rangeStart.getMonth() - eventStart.getMonth());
periods = Math.floor(months / interval);
break;
}
case 'yearly':
periods = Math.floor((rangeStart.getFullYear() - eventStart.getFullYear()) / interval);
break;
default:
return new Date(eventStart);
}
// Land one full period early so candidates inside the boundary period
// (which can precede the period anchor) are still generated.
periods = Math.max(0, periods - 1);
if (periods === 0) return new Date(eventStart);
switch (frequency) {
case 'secondly': return new Date(eventStart.getTime() + periods * interval * 1000);
case 'minutely': return new Date(eventStart.getTime() + periods * interval * 60000);
case 'hourly': return new Date(eventStart.getTime() + periods * interval * 3600000);
case 'daily': return addDays(eventStart, periods * interval);
case 'weekly': return addWeeks(eventStart, periods * interval);
case 'monthly': return addMonths(eventStart, periods * interval);
case 'yearly': return addYears(eventStart, periods * interval);
default: return new Date(eventStart);
}
}
function expandByDayInMonth(
year: number,
month: number,
+246
View File
@@ -0,0 +1,246 @@
/**
* Custom recurrence rule helpers for the event editor. Mobile port of
* webmail's components/calendar/recurrence-editor.tsx logic (rule detection,
* construction, and the human-readable summary).
*/
import { format } from 'date-fns';
import type { RecurrenceRule } from '../api/types';
export type EditorFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly';
export type MonthlyMode = 'day' | 'nth';
export type EndsMode = 'never' | 'on' | 'after';
export const EDITOR_FREQUENCIES: EditorFrequency[] = ['daily', 'weekly', 'monthly', 'yearly'];
// JSCalendar weekday ids in display order (week starts Monday, matching the
// firstDayOfWeek the built rules declare).
export const WEEKDAYS: string[] = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'];
const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 };
export const INDEX_TO_DAY = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
export const UNIT_LABELS: Record<EditorFrequency, string> = {
daily: 'days',
weekly: 'weeks',
monthly: 'months',
yearly: 'years',
};
// 2024-01-01 is a Monday — used to render weekday names via date-fns.
export function weekdayName(day: string, style: 'long' | 'short' = 'long'): string {
const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1);
return format(ref, style === 'long' ? 'EEEE' : 'EEE');
}
export function monthName(month: number): string {
return format(new Date(2024, month - 1, 1), 'LLLL');
}
const NTH_LABELS: Record<number, string> = {
1: 'first',
2: 'second',
3: 'third',
4: 'fourth',
[-1]: 'last',
};
export function nthLabel(nth: number): string {
return NTH_LABELS[nth] ?? String(nth);
}
export function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Extract an "nth weekday" pattern from a rule, accepting both the
* byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding.
*/
export function getNthDay(rule: RecurrenceRule): { day: string; nth: number } | null {
if (rule.byDay?.length === 1) {
const nd = rule.byDay[0];
if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod };
if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] };
}
return null;
}
/**
* True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly
* dropdown presets produce, i.e. it needs no custom editor to represent.
*/
export function isSimpleRecurrenceRule(rule: RecurrenceRule): boolean {
return (
(EDITOR_FREQUENCIES as string[]).includes(rule.frequency) &&
(!rule.interval || rule.interval === 1) &&
!rule.byDay?.length &&
!rule.byMonthDay?.length &&
!rule.byMonth?.length &&
!rule.byYearDay?.length &&
!rule.byWeekNo?.length &&
!rule.bySetPosition?.length &&
!rule.count &&
!rule.until
);
}
/**
* Human-readable summary of a recurrence rule, e.g.
* "Every 2 months on the third Thursday · 12 occurrences".
* Returns null for frequencies the UI cannot describe (hourly etc.).
*/
export function buildRecurrenceSummary(rule: RecurrenceRule): string | null {
const interval = rule.interval || 1;
let base: string;
switch (rule.frequency) {
case 'daily':
base = interval > 1 ? `Every ${interval} days` : 'Daily';
break;
case 'weekly':
base = interval > 1 ? `Every ${interval} weeks` : 'Weekly';
break;
case 'monthly':
base = interval > 1 ? `Every ${interval} months` : 'Monthly';
break;
case 'yearly':
base = interval > 1 ? `Every ${interval} years` : 'Yearly';
break;
default:
return null;
}
const parts = [base];
if (rule.frequency === 'weekly' && rule.byDay?.length) {
const days = rule.byDay
.filter((d) => WEEKDAYS.includes(d.day))
.sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day))
.map((d) => weekdayName(d.day, 'short'))
.join(', ');
if (days) parts.push(`on ${days}`);
}
if (rule.frequency === 'monthly' || rule.frequency === 'yearly') {
if (rule.frequency === 'yearly' && rule.byMonth?.length) {
const m = parseInt(rule.byMonth[0], 10);
if (m >= 1 && m <= 12) parts.push(`in ${monthName(m)}`);
}
const nthDay = getNthDay(rule);
if (nthDay) {
parts.push(`on the ${nthLabel(nthDay.nth)} ${weekdayName(nthDay.day)}`);
} else if (rule.byMonthDay?.length) {
parts.push(`on day ${rule.byMonthDay[0]}`);
}
}
let summary = parts.join(' ');
if (rule.count) {
summary += ` · ${rule.count} ${rule.count === 1 ? 'occurrence' : 'occurrences'}`;
} else if (rule.until) {
const d = new Date(rule.until);
if (!isNaN(d.getTime())) {
summary += ` · until ${format(d, 'MMM d, yyyy')}`;
}
}
return summary;
}
export interface RecurrenceEditorValue {
frequency: EditorFrequency;
interval: number;
weekDays: string[];
monthlyMode: MonthlyMode;
monthDay: number;
nth: number;
nthDay: string;
month: number;
endsMode: EndsMode;
untilDate: string; // yyyy-MM-dd
count: number;
}
/** Build the editor's initial state from an existing rule (or defaults). */
export function editorValueFromRule(
rule: RecurrenceRule | null,
eventStart: Date,
): RecurrenceEditorValue {
const startDay = INDEX_TO_DAY[eventStart.getDay()];
const nthDayInfo = rule ? getNthDay(rule) : null;
const weekDays = (() => {
if (rule?.frequency === 'weekly' && rule.byDay?.length) {
const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d));
if (days.length) return days;
}
return [startDay];
})();
const monthFromRule = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN;
const untilFromRule = (() => {
if (rule?.until) {
const d = new Date(rule.until);
if (!isNaN(d.getTime())) return format(d, 'yyyy-MM-dd');
}
const inAYear = new Date(eventStart);
inAYear.setFullYear(inAYear.getFullYear() + 1);
return format(inAYear, 'yyyy-MM-dd');
})();
return {
frequency: rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency)
? (rule.frequency as EditorFrequency)
: 'weekly',
interval: rule?.interval || 1,
weekDays,
monthlyMode: nthDayInfo ? 'nth' : 'day',
monthDay: rule?.byMonthDay?.[0] && rule.byMonthDay[0] >= 1 && rule.byMonthDay[0] <= 31
? rule.byMonthDay[0]
: eventStart.getDate(),
nth: nthDayInfo && (nthDayInfo.nth === -1 || (nthDayInfo.nth >= 1 && nthDayInfo.nth <= 4))
? nthDayInfo.nth
: Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1),
nthDay: nthDayInfo && WEEKDAYS.includes(nthDayInfo.day) ? nthDayInfo.day : startDay,
month: monthFromRule >= 1 && monthFromRule <= 12 ? monthFromRule : eventStart.getMonth() + 1,
endsMode: rule?.count ? 'after' : rule?.until ? 'on' : 'never',
untilDate: untilFromRule,
count: rule?.count ?? 12,
};
}
/** Build the JSCalendar rule the editor state describes. */
export function buildRuleFromEditorValue(
value: RecurrenceEditorValue,
eventStart: Date,
): RecurrenceRule {
const startDay = INDEX_TO_DAY[eventStart.getDay()];
const built: RecurrenceRule = {
'@type': 'RecurrenceRule',
frequency: value.frequency,
interval: Math.max(1, value.interval),
rscale: 'gregorian',
skip: 'omit',
firstDayOfWeek: 'mo',
};
if (value.endsMode === 'after') {
built.count = Math.max(1, value.count);
} else if (value.endsMode === 'on' && value.untilDate) {
built.until = `${value.untilDate}T23:59:59`;
}
if (value.frequency === 'weekly') {
const days = value.weekDays.length ? value.weekDays : [startDay];
built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day }));
} else if (value.frequency === 'monthly' || value.frequency === 'yearly') {
if (value.monthlyMode === 'nth') {
built.byDay = [{ day: value.nthDay, nthOfPeriod: value.nth }];
} else {
built.byMonthDay = [Math.min(31, Math.max(1, value.monthDay))];
}
if (value.frequency === 'yearly') {
built.byMonth = [String(value.month)];
}
}
return built;
}
+71 -7
View File
@@ -52,9 +52,13 @@ import { TasksSheet } from '../components/calendar/TasksSheet';
import { ICalImportSheet } from '../components/calendar/ICalImportSheet';
import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet';
import {
applySharedCalendarColors,
buildEventDayIndex,
eventsOnDayFromIndex,
pickUnusedCalendarColor,
sharedCalendarColorKey,
type EventDayIndex,
type TimeFormat,
} from '../lib/calendar-utils';
import { buildReplyTo } from '../lib/calendar-invitation';
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
@@ -117,6 +121,9 @@ export default function CalendarScreen() {
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar);
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
const contacts = useContactsStore((s) => s.contacts);
const [currentDate, setCurrentDate] = React.useState(new Date());
@@ -154,6 +161,7 @@ export default function CalendarScreen() {
const toggleTaskComplete = useCalendarStore((s) => s.toggleTaskComplete);
const deleteTask = useCalendarStore((s) => s.deleteTask);
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
const storeCalendars = useCalendarStore((s) => s.calendars);
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
const loading = useCalendarStore((s) => s.loading);
@@ -169,9 +177,41 @@ export default function CalendarScreen() {
return generateBirthdayEvents(contacts, after.toISOString(), before.toISOString());
}, [showBirthdayCalendar, contacts, viewMode, currentDate, calendarFirstDayOfWeek]);
// Per-viewer recolor (#345): shared calendars get the viewer's local color
// override applied before anything renders. Personal calendars pass through.
const displayCalendars = React.useMemo(
() => applySharedCalendarColors(storeCalendars, sharedCalendarColors),
[storeCalendars, sharedCalendarColors],
);
// Auto-assign a random, not-yet-used palette color to any freshly shared
// calendar so multiple shared calendars don't collide on one color. Runs
// once per calendar (guarded by the presence of an existing key), and the
// user can still overwrite it from the sidebar.
React.useEffect(() => {
const missing = storeCalendars.filter(
(cal) => cal.isShared && !sharedCalendarColors[sharedCalendarColorKey(cal)],
);
if (missing.length === 0) return;
// Seed "used" with personal calendar colors plus already-assigned shared
// overrides so the picks stay distinct from what's already on screen.
const used = new Set<string>();
for (const cal of storeCalendars) {
if (!cal.isShared && cal.color) used.add(cal.color.toLowerCase());
}
for (const color of Object.values(sharedCalendarColors)) {
if (color) used.add(color.toLowerCase());
}
for (const cal of missing) {
const color = pickUnusedCalendarColor(used);
used.add(color.toLowerCase());
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
}
}, [storeCalendars, sharedCalendarColors, setSharedCalendarColor]);
const allCalendars = React.useMemo(
() => (showBirthdayCalendar ? [...storeCalendars, createBirthdayCalendar()] : storeCalendars),
[storeCalendars, showBirthdayCalendar],
() => (showBirthdayCalendar ? [...displayCalendars, createBirthdayCalendar()] : displayCalendars),
[displayCalendars, showBirthdayCalendar],
);
const allEvents = React.useMemo(
() => (birthdayEvents.length > 0 ? [...storeEvents, ...birthdayEvents] : storeEvents),
@@ -252,25 +292,34 @@ export default function CalendarScreen() {
[],
);
// A recurring series member is a master (has recurrenceRules) or an expanded
// occurrence (has recurrenceId) — those get the this/all scope dialog. Note:
// we must NOT key off originalId here, since shared/group events now carry it
// for id-namespacing even when they aren't recurring.
const isRecurringSeriesMember = React.useCallback(
(event: CalendarEvent) => !!event.recurrenceRules?.length || !!event.recurrenceId,
[],
);
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
if (isRecurringSeriesMember(event)) {
setPendingAction({ kind: 'edit', event });
} else {
openEditDirect(event);
}
}, [openEditDirect, isReadOnlyEvent]);
}, [openEditDirect, isReadOnlyEvent, isRecurringSeriesMember]);
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
if (isRecurringSeriesMember(event)) {
setPendingAction({ kind: 'delete', event });
} else {
void deleteEvent(event.id);
}
}, [deleteEvent, isReadOnlyEvent]);
}, [deleteEvent, isReadOnlyEvent, isRecurringSeriesMember]);
const handleScopeSelect = React.useCallback(
async (scope: RecurrenceEditScope) => {
@@ -420,6 +469,7 @@ export default function CalendarScreen() {
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
timeFormat={calendarTimeFormat}
onSelectEvent={setDetailEvent}
/>
)}
@@ -436,6 +486,7 @@ export default function CalendarScreen() {
date={selectedDate}
eventsByDay={eventsByDay}
calendars={calendars}
timeFormat={calendarTimeFormat}
onSelectEvent={setDetailEvent}
refreshing={refreshing}
onRefresh={onRefresh}
@@ -447,6 +498,7 @@ export default function CalendarScreen() {
<EventDetailSheet
event={detailEvent}
calendars={calendars}
timeFormat={calendarTimeFormat}
onClose={() => setDetailEvent(null)}
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
@@ -491,6 +543,13 @@ export default function CalendarScreen() {
onClose={() => setSidebarVisible(false)}
onImport={() => { setSidebarVisible(false); setImportVisible(true); }}
onManageSubscriptions={() => { setSidebarVisible(false); setSubscriptionsVisible(true); }}
onSetDefault={(cal) => { void setDefaultCalendar(cal.id); }}
onSetColor={(cal, color) => setSharedCalendarColor(sharedCalendarColorKey(cal), color)}
onResetColor={(cal) => {
// Drop the local override; the auto-assign effect picks a fresh
// unused color (so it never reverts to a collision).
removeSharedCalendarColor(sharedCalendarColorKey(cal));
}}
/>
<TasksSheet
@@ -505,7 +564,9 @@ export default function CalendarScreen() {
<ICalImportSheet
visible={importVisible}
calendars={storeCalendars}
// Import batch-creates against the primary account, so only offer the
// user's own calendars as targets.
calendars={displayCalendars.filter((cal) => !cal.isShared)}
onClose={() => setImportVisible(false)}
onImport={importEvents}
/>
@@ -522,6 +583,7 @@ function DayEventList({
date,
eventsByDay,
calendars,
timeFormat,
onSelectEvent,
refreshing,
onRefresh,
@@ -529,6 +591,7 @@ function DayEventList({
date: Date;
eventsByDay: EventDayIndex;
calendars: Calendar[];
timeFormat?: TimeFormat;
onSelectEvent?: (event: CalendarEvent) => void;
refreshing: boolean;
onRefresh: () => void;
@@ -565,6 +628,7 @@ function DayEventList({
key={event.id}
event={event}
calendars={calendars}
timeFormat={timeFormat}
onPress={onSelectEvent}
/>
))}
+104 -31
View File
@@ -17,7 +17,7 @@ import * as FileSystem from 'expo-file-system/legacy';
import { File as FsFile } from 'expo-file-system';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button } from '../components';
import { Button, IdentitySheet } from '../components';
import RichTextEditor, {
type RichTextEditorHandle,
type RichTextSelectionState,
@@ -26,6 +26,7 @@ import { useEmailStore } from '../stores/email-store';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import { useAccountStore } from '../stores/account-store';
import { isGroup } from '../lib/contact-utils';
import {
getContactDisplayName,
@@ -69,7 +70,16 @@ function parseRecipients(input: string): Recipient[] {
.split(/[,;\n]/)
.map((s) => s.trim())
.filter(Boolean)
.map((email) => ({ name: '', email }));
.map((s) => {
const match = s.match(/^(.*?)\s*<([^\s@]+@[^\s@]+\.[^\s@]+)>$/);
if (match) {
return {
name: match[1].replace(/^["']|["']$/g, '').trim(),
email: match[2].trim(),
};
}
return { name: '', email: s };
});
}
function formatBytes(n: number): string {
@@ -113,10 +123,11 @@ function RecipientChip({ recipient, onRemove }: { recipient: Recipient; onRemove
}
function SuggestionList({
suggestions, onPick,
suggestions, onPick, onPressIn,
}: {
suggestions: Array<{ contact: ContactCard; name: string; email: string }>;
onPick: (s: { name: string; email: string }) => void;
onPressIn?: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -127,6 +138,7 @@ function SuggestionList({
return (
<Pressable
key={`${s.contact.id}-${s.email}-${i}`}
onPressIn={onPressIn}
onPress={() => onPick(s)}
style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]}
>
@@ -260,6 +272,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const [identityError, setIdentityError] = React.useState<string | null>(null);
const [sending, setSending] = React.useState(false);
const [selectedIdentityId, setSelectedIdentityId] = React.useState<string | null>(null);
const [identitySheetOpen, setIdentitySheetOpen] = React.useState(false);
const [scheduleSheetOpen, setScheduleSheetOpen] = React.useState(false);
// Custom date/time picker stage. iOS shows one 'datetime' spinner; Android
// can only show one field at a time, so we walk date → time.
@@ -343,6 +356,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
});
const editorRef = React.useRef<RichTextEditorHandle>(null);
const isPickingSuggestion = React.useRef(false);
// Track inline-image placeholders that haven't yet been rewritten to cid:
// until send time. Maps cid → blobId/type/name/size.
const inlineRegistryRef = React.useRef<Map<string, AttachmentEntry>>(new Map());
@@ -375,6 +389,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}, [suggestionQuery, individuals, alreadySelected]);
const pickSuggestion = (s: { name: string; email: string }) => {
isPickingSuggestion.current = true;
const recipient: Recipient = { name: s.name, email: s.email };
if (activeField === 'cc') {
setCcRecipients((prev) => [...prev, recipient]);
@@ -405,34 +420,35 @@ export default function ComposeScreen({ route, navigation }: Props) {
// original message (matches webmail's reply-identity behaviour).
React.useEffect(() => {
if (selectedIdentityId || identities.length === 0) return;
const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username;
const defaultIdentity = identities.find(
(i) => i.email.toLowerCase() === activeEmail?.toLowerCase()
) ?? identities[0];
if (autoSelectReplyIdentity && replyTo) {
const candidates = [...(replyTo.to ?? []), ...(replyTo.cc ?? [])];
const candidateAddrs = new Set(
candidates.map((r) => r.email?.toLowerCase()).filter(Boolean) as string[],
);
const matched = identities.find((i) => candidateAddrs.has(i.email.toLowerCase()));
setSelectedIdentityId((matched ?? identities[0]).id);
setSelectedIdentityId((matched ?? defaultIdentity).id);
} else {
setSelectedIdentityId(identities[0].id);
setSelectedIdentityId(defaultIdentity.id);
}
}, [identities, autoSelectReplyIdentity, replyTo, selectedIdentityId]);
const primaryIdentity =
identities.find((i) => i.id === selectedIdentityId) ?? identities[0];
const primaryIdentity = React.useMemo(() => {
if (identities.length === 0) return null;
const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username;
const defaultIdentity = identities.find(
(i) => i.email.toLowerCase() === activeEmail?.toLowerCase()
) ?? identities[0];
return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity;
}, [identities, selectedIdentityId]);
const openIdentityPicker = () => {
if (identities.length <= 1) return;
Alert.alert(
t('email_composer.from', 'From'),
undefined,
[
...identities.map((i) => ({
text: i.name ? `${i.name} <${i.email}>` : i.email,
onPress: () => setSelectedIdentityId(i.id),
})),
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' as const },
],
);
setIdentitySheetOpen(true);
};
const commitTyped = () => {
@@ -448,11 +464,33 @@ export default function ComposeScreen({ route, navigation }: Props) {
const extraTo = parseRecipients(toInput);
const extraCc = parseRecipients(ccInput);
if (extraTo.length) {
setToRecipients((prev) => [...prev, ...extraTo]);
setToRecipients((prev) => {
const existing = new Set(prev.map((r) => r.email.toLowerCase()));
const unique: Recipient[] = [];
for (const r of extraTo) {
const emailLower = r.email.toLowerCase();
if (!existing.has(emailLower)) {
existing.add(emailLower);
unique.push(r);
}
}
return [...prev, ...unique];
});
setToInput('');
}
if (extraCc.length) {
setCcRecipients((prev) => [...prev, ...extraCc]);
setCcRecipients((prev) => {
const existing = new Set(prev.map((r) => r.email.toLowerCase()));
const unique: Recipient[] = [];
for (const r of extraCc) {
const emailLower = r.email.toLowerCase();
if (!existing.has(emailLower)) {
existing.add(emailLower);
unique.push(r);
}
}
return [...prev, ...unique];
});
setCcInput('');
}
};
@@ -976,7 +1014,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<View style={[styles.flex, { paddingBottom: bottomPad }]}>
<ScrollView
style={styles.flex}
keyboardShouldPersistTaps="handled"
keyboardShouldPersistTaps="always"
contentContainerStyle={styles.scrollContent}
>
<View style={styles.fieldRow}>
@@ -993,7 +1031,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</Pressable>
</View>
<View style={styles.fieldRow}>
<View style={[styles.fieldRow, { zIndex: 5 }]}>
<Text style={styles.fieldLabel}>{t('email_composer.to', 'To')}</Text>
<View style={styles.recipientField}>
{toRecipients.map((r, i) => (
@@ -1011,8 +1049,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
onChangeText={setToInput}
onFocus={() => setActiveField('to')}
onBlur={() => {
addTyped();
setTimeout(() => setActiveField((f) => (f === 'to' ? null : f)), 200);
setTimeout(() => {
if (!isPickingSuggestion.current) {
addTyped();
}
setActiveField((f) => (f === 'to' ? null : f));
isPickingSuggestion.current = false;
}, 200);
}}
onSubmitEditing={addTyped}
keyboardType="email-address"
@@ -1021,7 +1064,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
/>
</View>
{activeField === 'to' && suggestions.length > 0 && (
<SuggestionList suggestions={suggestions} onPick={pickSuggestion} />
<SuggestionList
suggestions={suggestions}
onPick={pickSuggestion}
onPressIn={() => {
isPickingSuggestion.current = true;
}}
/>
)}
{!ccVisible && (
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle}>
@@ -1031,7 +1080,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
{ccVisible && (
<View style={styles.fieldRow}>
<View style={[styles.fieldRow, { zIndex: 4 }]}>
<Text style={styles.fieldLabel}>{t('email_composer.cc', 'Cc')}</Text>
<View style={styles.recipientField}>
{ccRecipients.map((r, i) => (
@@ -1049,8 +1098,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
onChangeText={setCcInput}
onFocus={() => setActiveField('cc')}
onBlur={() => {
addTyped();
setTimeout(() => setActiveField((f) => (f === 'cc' ? null : f)), 200);
setTimeout(() => {
if (!isPickingSuggestion.current) {
addTyped();
}
setActiveField((f) => (f === 'cc' ? null : f));
isPickingSuggestion.current = false;
}, 200);
}}
onSubmitEditing={addTyped}
keyboardType="email-address"
@@ -1059,7 +1113,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
/>
</View>
{activeField === 'cc' && suggestions.length > 0 && (
<SuggestionList suggestions={suggestions} onPick={pickSuggestion} />
<SuggestionList
suggestions={suggestions}
onPick={pickSuggestion}
onPressIn={() => {
isPickingSuggestion.current = true;
}}
/>
)}
</View>
)}
@@ -1293,6 +1353,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
onChange={onCustomPickerChange}
/>
)}
<IdentitySheet
visible={identitySheetOpen}
onClose={() => setIdentitySheetOpen(false)}
identities={identities}
selectedIdentityId={selectedIdentityId}
onPick={(identity) => setSelectedIdentityId(identity.id)}
/>
</SafeAreaView>
);
}
@@ -1332,6 +1400,7 @@ function makeStyles(c: ThemePalette) {
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
gap: spacing.md,
position: 'relative',
},
fieldLabel: {
...typography.body,
@@ -1374,12 +1443,16 @@ function makeStyles(c: ThemePalette) {
paddingVertical: 6,
},
suggestionBox: {
marginTop: spacing.xs,
marginLeft: 60,
position: 'absolute',
top: '100%',
left: spacing.lg + 56 + spacing.md,
right: spacing.lg,
borderWidth: 1,
borderColor: c.borderLight,
borderRadius: radius.sm,
backgroundColor: c.card,
zIndex: 20,
elevation: 5,
overflow: 'hidden',
},
suggestionRow: {
+24 -8
View File
@@ -24,8 +24,8 @@ import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
import { useKeywordsStore } from '../stores/keywords-store';
import { useLocaleStore } from '../stores/locale-store';
import { formatListDate } from '../lib/date-format';
import { findTrashMailbox, findArchiveMailbox, findJunkMailbox } from '../lib/mailbox-tree';
import type { Email } from '../api/types';
function getSenderName(email: Email): string {
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
}
@@ -164,6 +164,7 @@ interface EmailListScreenProps {
export default function EmailListScreen({ onEmailPress, onComposePress }: EmailListScreenProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { t } = useLocaleStore();
const [drawerOpen, setDrawerOpen] = React.useState(false);
const [filterMenuOpen, setFilterMenuOpen] = React.useState(false);
const emails = useEmailStore((s) => s.emails);
@@ -232,15 +233,15 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}, [emails, disableThreading]);
const archiveMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
() => findArchiveMailbox(mailboxes)?.id ?? null,
[mailboxes],
);
const trashMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'trash')?.id ?? null,
() => findTrashMailbox(mailboxes)?.id ?? null,
[mailboxes],
);
const junkMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam')?.id ?? null,
() => findJunkMailbox(mailboxes)?.id ?? null,
[mailboxes],
);
@@ -336,16 +337,31 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
case 'archive':
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
void archiveEmailAction(id);
} else if (!archiveMailboxId) {
Alert.alert(
t('email_list.error', 'Error'),
t('email_list.no_archive_folder', 'Could not find an Archive folder on the server.'),
);
}
break;
case 'delete':
if (trashMailboxId) {
void deleteEmailAction(id, trashMailboxId, currentMailboxId);
} else {
Alert.alert(
t('email_list.error', 'Error'),
t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.'),
);
}
break;
case 'spam':
if (junkMailboxId && currentMailboxId !== junkMailboxId) {
void moveToMailboxAction(id, currentMailboxId, junkMailboxId);
} else if (!junkMailboxId) {
Alert.alert(
t('email_list.error', 'Error'),
t('email_list.no_junk_folder', 'Could not find a Spam/Junk folder on the server.'),
);
}
break;
case 'read':
@@ -365,7 +381,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}, [
currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId,
moveToMailboxAction, archiveEmailAction, deleteEmailAction,
markRead, markUnread, toggleStar, togglePin,
markRead, markUnread, toggleStar, togglePin, t,
]);
const renderEmailRow = React.useCallback(
@@ -433,7 +449,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
};
const handleBulkDelete = async () => {
const trash = mailboxes.find((m) => m.role === 'trash');
const trash = findTrashMailbox(mailboxes);
if (!trash || !currentMailboxId) {
clearSelection();
return;
@@ -593,7 +609,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
<Menu size={20} color={c.textMuted} />
</Pressable>
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
<Text style={styles.headerTitle} numberOfLines={1}>{currentMailbox?.name ?? 'Inbox'}</Text>
<View style={{ flex: 1 }} />
<Pressable
onPress={() => { void handleImport(); }}
@@ -1075,7 +1091,7 @@ function makeStyles(c: ThemePalette) {
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { ...typography.h3, color: c.text, flex: 1 }, // text-lg font-semibold
headerTitle: { ...typography.h3, color: c.text, flexShrink: 1 }, // text-lg font-semibold
headerLogo: {
width: 28,
height: 28,
+553 -214
View File
@@ -1,8 +1,9 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
Modal, useWindowDimensions, Animated, Easing, Alert,
Modal, useWindowDimensions, Animated, Easing, Alert, FlatList,
} from 'react-native';
import type { NativeSyntheticEvent, NativeScrollEvent } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import {
@@ -18,11 +19,18 @@ import { CalendarInvitationBanner } from '../components/email/CalendarInvitation
import SenderAvatar from '../components/SenderAvatar';
import { MoveSheet } from '../components/MoveSheet';
import { useEmailStore } from '../stores/email-store';
import { useSettingsStore } from '../stores/settings-store';
import {
useSettingsStore,
normalizeBottomQuickActions,
REPLY_QUICK_ACTIONS,
type QuickAction,
} from '../stores/settings-store';
import { setEmailKeywords } from '../api/email';
import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { findTrashMailbox } from '../lib/mailbox-tree';
import type { Email, Mailbox } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
@@ -30,8 +38,13 @@ type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
function formatHeaderDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
return d.toLocaleDateString(undefined, {
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
});
}
function formatHeaderTime(iso: string): string {
return new Date(iso).toLocaleTimeString(undefined, {
hour: '2-digit', minute: '2-digit',
});
}
@@ -54,7 +67,12 @@ function plainTextBody(email: Email): string {
export default function EmailThreadScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { emailId, subject: subjectParam, jmapAccountId } = route.params;
const { t } = useLocaleStore();
const { jmapAccountId } = route.params;
// The displayed email is tracked in local state (not a route param) so that
// swiping / Prev-Next can switch messages in place without remounting the
// screen — which is what produced the loading flash on every change.
const [activeEmailId, setActiveEmailId] = React.useState(route.params.emailId);
const insets = useSafeAreaInsets();
const { width: windowWidth } = useWindowDimensions();
const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
@@ -66,40 +84,115 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const emails = useEmailStore((s) => s.emails);
const currentIndex = emails.findIndex((e) => e.id === emailId);
const currentIndex = emails.findIndex((e) => e.id === activeEmailId);
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
const goToEmail = (target: { id: string; threadId: string; subject?: string }) => {
navigation.replace('EmailThread', {
emailId: target.id,
threadId: target.threadId,
subject: target.subject ?? '',
});
};
const [email, setEmail] = React.useState<Email | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
const [downloadingBlobId, setDownloadingBlobId] = React.useState<string | null>(null);
// In-memory cache of fetched message details keyed by id. This is the single
// source of truth for every rendered pane: the active message *and* its
// prefetched neighbours all read their detail from here, so a swipe lands on
// ready content with no spinner and no late content-swap. `cacheVersion` is
// bumped whenever an entry changes so the panes re-render.
const detailCache = React.useRef(new Map<string, Email>()).current;
const [cacheVersion, setCacheVersion] = React.useState(0);
const bumpCache = React.useCallback(() => setCacheVersion((v) => v + 1), []);
const email = React.useMemo(
() => detailCache.get(activeEmailId) ?? null,
// eslint-disable-next-line react-hooks/exhaustive-deps
[activeEmailId, cacheVersion, detailCache],
);
// --- Pager -------------------------------------------------------------
// The pager is a horizontal, page-snapping FlatList over the mailbox's
// `emails`. Native scroll provides the swipe + snap (and plays nicely with
// each pane's vertical body scroll); Prev/Next scroll programmatically. When
// scrolling settles, the centred page's id becomes `activeEmailId`, which is
// what the toolbar and action handlers operate on.
const listRef = React.useRef<FlatList<Email>>(null);
// The page to open on mount — the entry point arrives as a route param, so
// capture its index once.
const initialIndexRef = React.useRef(
Math.max(0, emails.findIndex((e) => e.id === route.params.emailId)),
);
// Coalesced detail fetch: stores the result in the shared cache and bumps the
// version so every mounted pane (and the toolbar) re-reads it. Concurrent
// calls for the same id share a single in-flight request.
const inFlight = React.useRef(new Map<string, Promise<Email | null>>()).current;
const ensureDetail = React.useCallback((id: string): Promise<Email | null> => {
const pending = inFlight.get(id);
if (pending) return pending;
const p = getEmailDetail(id, jmapAccountId)
.then((fetched) => { detailCache.set(id, fetched); bumpCache(); return fetched; })
.catch(() => detailCache.get(id) ?? null)
.finally(() => { inFlight.delete(id); });
inFlight.set(id, p);
return p;
}, [getEmailDetail, jmapAccountId, detailCache, bumpCache, inFlight]);
// Slide to a page by index and reflect it in the toolbar immediately; a swipe
// that settles on the same page confirms the same id via onMomentumEnd.
const goToIndex = React.useCallback((index: number) => {
if (index < 0 || index >= emails.length) return;
listRef.current?.scrollToOffset({ offset: index * windowWidth, animated: true });
const target = emails[index];
if (target) setActiveEmailId(target.id);
}, [emails, windowWidth]);
// Adopt the centred page as the active message once a swipe settles.
const onMomentumEnd = React.useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
const index = Math.round(e.nativeEvent.contentOffset.x / windowWidth);
const target = emails[index];
if (target && target.id !== activeEmailId) setActiveEmailId(target.id);
}, [emails, windowWidth, activeEmailId]);
// Page offsets are in window-width units, so a rotation / resize invalidates
// the scroll position — re-centre the active page after the width changes.
React.useLayoutEffect(() => {
const index = emails.findIndex((e) => e.id === activeEmailId);
if (index >= 0) listRef.current?.scrollToOffset({ offset: index * windowWidth, animated: false });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowWidth]);
// While the body is pinch-zoomed (or a pinch is in flight) horizontal
// gestures pan the zoomed content, so the pager must not treat them as page
// swipes. Prev/Next stay usable: programmatic scrollToOffset ignores
// scrollEnabled. Reset when the active message changes — the zoomed pane's
// WebView can unmount without reporting back.
const [pagerLocked, setPagerLocked] = React.useState(false);
React.useEffect(() => { setPagerLocked(false); }, [activeEmailId]);
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
const bottomActions = React.useMemo(
() => normalizeBottomQuickActions(bottomQuickActionsRaw),
[bottomQuickActionsRaw],
);
// Any reply-family action the user pulled out of the bottom bar is surfaced
// in the top toolbar so it stays reachable.
const relocatedActions = React.useMemo(
() => REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a)),
[bottomActions],
);
const onPressAttachment = React.useCallback(
async (blobId: string, name: string | undefined, type: string | undefined) => {
async (target: Email, blobId: string, name: string | undefined, type: string | undefined) => {
if (downloadingBlobId) return;
setDownloadingBlobId(blobId);
try {
if (mailAttachmentAction === 'download') {
await downloadAttachment(blobId, name, type, email);
await downloadAttachment(blobId, name, type, target);
} else {
await shareAttachment(blobId, name, type, email);
await shareAttachment(blobId, name, type, target);
}
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
@@ -107,66 +200,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
setDownloadingBlobId(null);
}
},
[downloadingBlobId, mailAttachmentAction, email],
[downloadingBlobId, mailAttachmentAction],
);
const renderAttachments = () => {
const all = email?.attachments;
if (!all || all.length === 0) return null;
// hideInlineImageAttachments: drop chips for images already shown inline
// in the body (any image attachment with a cid:). Non-image inline
// attachments stay visible because the user wouldn't see them otherwise.
const atts = hideInlineImageAttachments
? all.filter((att) => !(att.cid && (att.type ?? '').startsWith('image/')))
: all;
if (atts.length === 0) return null;
const visible = attachmentsExpanded ? atts : atts.slice(0, 3);
return (
<View style={styles.attachmentsBlock}>
<View style={styles.attachmentsRow}>
{visible.map((att, idx) => {
const isDownloading = downloadingBlobId === att.blobId;
return (
<Pressable
key={att.blobId ?? idx}
style={({ pressed }) => [
styles.attachmentChip,
pressed && styles.attachmentChipPressed,
]}
onPress={() => onPressAttachment(att.blobId, att.name, att.type)}
disabled={!!downloadingBlobId}
>
{isDownloading ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Paperclip size={14} color={c.textMuted} />
)}
<Text style={styles.attachmentName} numberOfLines={1}>
{att.name || 'attachment'}
</Text>
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
<Download size={14} color={c.textMuted} />
</Pressable>
);
})}
</View>
{atts.length > 3 && (
<Pressable
onPress={() => setAttachmentsExpanded((v) => !v)}
style={({ pressed }) => [
styles.attachmentsToggle,
pressed && styles.attachmentsTogglePressed,
]}
hitSlop={6}
>
<Text style={styles.attachmentsToggleText}>
{attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`}
</Text>
</Pressable>
)}
</View>
);
};
const keywordDefs = useKeywordsStore((s) => s.keywords);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
@@ -175,35 +210,34 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
React.useEffect(() => {
let cancelled = false;
let readTimer: ReturnType<typeof setTimeout> | null = null;
setLoading(true);
// Refresh the active message — a cached copy (the pane may have fetched it,
// or it was visited before) renders immediately while this lands. Then mark
// it read per the user's delay. The pane itself fetches its own detail, so
// ensureDetail here usually rides the same in-flight request.
setError(null);
void (async () => {
try {
const fetched = await getEmailDetail(emailId, jmapAccountId);
if (cancelled) return;
setEmail(fetched);
if (fetched && !fetched.keywords?.$seen) {
if (markAsReadDelay > 0) {
readTimer = setTimeout(() => {
if (!cancelled) void markRead(emailId, jmapAccountId);
}, markAsReadDelay);
} else {
void markRead(emailId, jmapAccountId);
}
const fetched = await ensureDetail(activeEmailId);
if (cancelled) return;
if (!fetched) {
// Only surface an error if there's nothing cached to show.
if (!detailCache.has(activeEmailId)) setError('Failed to load email');
return;
}
if (!fetched.keywords?.$seen) {
if (markAsReadDelay > 0) {
readTimer = setTimeout(() => {
if (!cancelled) void markRead(activeEmailId, jmapAccountId);
}, markAsReadDelay);
} else {
void markRead(activeEmailId, jmapAccountId);
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'Failed to load email');
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
if (readTimer) clearTimeout(readTimer);
};
}, [emailId, jmapAccountId, getEmailDetail, markRead, markAsReadDelay]);
}, [activeEmailId, ensureDetail, markRead, markAsReadDelay, jmapAccountId, detailCache]);
const starred = !!email?.keywords?.$flagged;
const unread = !!email && !email.keywords?.$seen;
@@ -222,8 +256,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
);
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
const updateLocalKeywords = (next: Record<string, boolean>) => {
setEmail((prev) => (prev ? { ...prev, keywords: next } : prev));
// Optimistically write a message's keywords into the cache (the single source
// of truth for every pane) and bump the version so the panes re-render.
const updateLocalKeywords = (id: string, next: Record<string, boolean>) => {
const prev = detailCache.get(id);
if (!prev) return;
detailCache.set(id, { ...prev, keywords: next });
bumpCache();
};
const onToggleKeyword = (token: string) => {
@@ -231,36 +270,46 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const next = { ...email.keywords };
if (next[token]) delete next[token];
else next[token] = true;
updateLocalKeywords(next);
updateLocalKeywords(email.id, next);
void setEmailKeywords(email.id, next);
};
const onToggleStar = () => {
if (!email) return;
const next = { ...email.keywords };
if (starred) delete next.$flagged;
// Toggle the star on a specific message — used both by the toolbar (current
// message) and by each pane's own subject star.
const toggleStarFor = React.useCallback((target: Email) => {
const next = { ...target.keywords };
if (next.$flagged) delete next.$flagged;
else next.$flagged = true;
updateLocalKeywords(next);
void setEmailKeywords(email.id, next);
};
const prev = detailCache.get(target.id);
if (prev) { detailCache.set(target.id, { ...prev, keywords: next }); bumpCache(); }
void setEmailKeywords(target.id, next);
}, [detailCache, bumpCache]);
const onToggleStar = () => { if (email) toggleStarFor(email); };
const onToggleUnread = () => {
if (!email) return;
if (unread) {
void markRead(email.id);
updateLocalKeywords({ ...email.keywords, $seen: true });
updateLocalKeywords(email.id, { ...email.keywords, $seen: true });
} else {
const next = { ...email.keywords };
delete next.$seen;
updateLocalKeywords(next);
updateLocalKeywords(email.id, next);
void setEmailKeywords(email.id, next);
}
};
const onDelete = () => {
if (!email || !currentMailboxId) return;
const trash = mailboxes.find((m) => m.role === 'trash');
if (!trash) return;
const trash = findTrashMailbox(mailboxes);
if (!trash) {
Alert.alert(
t('email_list.error', 'Error'),
t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.'),
);
return;
}
void deleteEmail(email.id, trash.id, currentMailboxId);
navigation.goBack();
};
@@ -312,8 +361,78 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
});
};
const subject = email?.subject ?? subjectParam ?? '(no subject)';
const from = email?.from?.[0];
// Registry of every action that can live in the bottom quick-action bar (or
// be relocated to the top toolbar). `icon` is a factory so the same action
// can render at the toolbar (18px) and bottom-bar (20px) sizes.
const quickActionRegistry: Record<
QuickAction,
{ label: string; icon: (size: number, color: string) => React.ReactNode; onPress: () => void; available: boolean }
> = {
reply: {
label: 'Reply',
icon: (s, col) => <Reply size={s} color={col} />,
onPress: () => navigateCompose('reply'),
available: true,
},
replyAll: {
label: 'Reply All',
icon: (s, col) => <ReplyAll size={s} color={col} />,
onPress: () => navigateCompose('replyAll'),
available: true,
},
forward: {
label: 'Forward',
icon: (s, col) => <Forward size={s} color={col} />,
onPress: () => navigateCompose('forward'),
available: true,
},
delete: {
label: 'Delete',
icon: (s, col) => <Trash2 size={s} color={col} />,
onPress: onDelete,
available: true,
},
archive: {
label: 'Archive',
icon: (s, col) => <Archive size={s} color={col} />,
onPress: onArchive,
available: !!archiveMailbox && currentMailboxId !== archiveMailbox?.id,
},
markUnread: {
label: unread ? 'Read' : 'Unread',
icon: (s, col) => (unread ? <MailOpen size={s} color={col} /> : <Mail size={s} color={col} />),
onPress: onToggleUnread,
available: true,
},
star: {
label: starred ? 'Unstar' : 'Star',
icon: (s, col) => (
<Star size={s} color={starred ? c.starred : col} fill={starred ? c.starred : 'transparent'} />
),
onPress: onToggleStar,
available: true,
},
move: {
label: 'Move',
icon: (s, col) => <FolderInput size={s} color={col} />,
onPress: () => setMoveMenuOpen(true),
available: mailboxes.length > 0,
},
spam: {
label: isInJunk ? 'Not spam' : 'Spam',
icon: (s, col) =>
isInJunk ? <ShieldCheck size={s} color={c.success} /> : <ShieldAlert size={s} color={col} />,
onPress: onToggleSpam,
available: !!junkMailbox || isInJunk,
},
tag: {
label: 'Tag',
icon: (s, col) => <Tag size={s} color={col} />,
onPress: () => setTagMenuOpen(true),
available: keywordDefs.length > 0,
},
};
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
// Show extras in priority order. Each toolbar button is ~64px wide (icon+label+padding);
@@ -329,6 +448,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<ArrowLeft size={22} color={c.text} />
</Pressable>
<View style={styles.toolbarActions}>
{relocatedActions.map((id) => {
const def = quickActionRegistry[id];
return (
<ToolbarButton
key={id}
icon={def.icon(18, c.textSecondary)}
label={def.label}
onPress={def.onPress}
/>
);
})}
<ToolbarButton
icon={<Trash2 size={18} color={c.textSecondary} />}
label="Delete"
@@ -373,110 +503,82 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
</View>
</View>
{loading ? (
<View style={styles.centered}>
<ActivityIndicator color={c.primary} />
</View>
) : error ? (
{error && !email ? (
<View style={styles.centered}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : email ? (
) : (
<>
<ScrollView
style={styles.scroll}
contentContainerStyle={{ paddingBottom: bottomBarHeight + spacing.lg }}
>
{/* Subject block */}
<View style={styles.subjectBlock}>
<View style={styles.subjectRow}>
<View style={styles.subjectMain}>
<Pressable onPress={onToggleStar} hitSlop={8} style={styles.subjectStar}>
<Star
size={18}
color={starred ? c.starred : c.textMuted}
fill={starred ? c.starred : 'transparent'}
/>
</Pressable>
<Text style={styles.subjectText}>{subject}</Text>
</View>
<View style={styles.subjectMeta}>
<Text style={styles.subjectDate}>{formatHeaderDate(email.receivedAt)}</Text>
{email.size > 0 && (
<Text style={styles.subjectSize}>{formatSize(email.size)}</Text>
)}
</View>
</View>
</View>
{/* Sender info */}
<View style={styles.senderBlock}>
<View style={styles.senderRow}>
<SenderAvatar
name={from?.name}
email={from?.email}
size={componentSizes.avatarMd}
{/* Pager: a horizontal page-snapping list over the mailbox. Each
page is the full-width pane for one message; native scroll slides
between them and onMomentumEnd reports the settled page. */}
<FlatList
ref={listRef}
style={styles.pagerViewport}
data={emails}
extraData={cacheVersion}
keyExtractor={(item) => item.id}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
initialScrollIndex={initialIndexRef.current}
getItemLayout={(_, index) => ({ length: windowWidth, offset: windowWidth * index, index })}
windowSize={3}
initialNumToRender={1}
maxToRenderPerBatch={2}
removeClippedSubviews
scrollEnabled={!pagerLocked}
onMomentumScrollEnd={onMomentumEnd}
renderItem={({ item, index }) => (
<View style={{ width: windowWidth }}>
<EmailPane
id={item.id}
email={detailCache.get(item.id) ?? null}
ensureDetail={ensureDetail}
c={c}
styles={styles}
bottomBarHeight={bottomBarHeight}
attachmentPosition={attachmentPosition}
hideInlineImageAttachments={hideInlineImageAttachments}
downloadingBlobId={downloadingBlobId}
onToggleStar={toggleStarFor}
onPressAttachment={onPressAttachment}
onSwipe={(dir) => goToIndex(dir === 'next' ? index + 1 : index - 1)}
onZoomChange={(z) => setPagerLocked(z.pinching || z.zoomed)}
/>
<View style={styles.senderInfo}>
<Text style={styles.senderName} numberOfLines={1}>
{from?.name || from?.email || 'Unknown sender'}
</Text>
{from?.name && from?.email ? (
<Text style={styles.senderEmail} numberOfLines={1}>{from.email}</Text>
) : null}
<Text style={styles.senderRecipients} numberOfLines={1}>
<Text style={styles.senderRecipientsLabel}>to </Text>
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
</Text>
</View>
</View>
{attachmentPosition === 'beside-sender' && renderAttachments()}
</View>
{/* Attachments chips (full-width below header) */}
{attachmentPosition === 'below-header' && renderAttachments()}
{/* Calendar invitation (auto-detected .ics) */}
<CalendarInvitationBanner email={email} />
{/* Body */}
<View style={styles.bodyBlock}>
<EmailBodyView email={email} senderEmail={from?.email} />
</View>
</ScrollView>
)}
/>
{/* Bottom action bar */}
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
<BottomBarButton
icon={<ChevronLeft size={20} color={c.textMuted} />}
label="Prev"
onPress={prevEmail ? () => goToEmail(prevEmail) : undefined}
onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined}
disabled={!prevEmail}
/>
<BottomBarButton
icon={<Reply size={20} color={c.textSecondary} />}
label="Reply"
onPress={() => navigateCompose('reply')}
/>
<BottomBarButton
icon={<ReplyAll size={20} color={c.textSecondary} />}
label="Reply All"
onPress={() => navigateCompose('replyAll')}
/>
<BottomBarButton
icon={<Forward size={20} color={c.textSecondary} />}
label="Forward"
onPress={() => navigateCompose('forward')}
/>
{bottomActions.map((id) => {
const def = quickActionRegistry[id];
return (
<BottomBarButton
key={id}
icon={def.icon(20, c.textSecondary)}
label={def.label}
onPress={def.available ? def.onPress : undefined}
disabled={!def.available}
/>
);
})}
<BottomBarButton
icon={<ChevronRight size={20} color={c.textMuted} />}
label="Next"
onPress={nextEmail ? () => goToEmail(nextEmail) : undefined}
onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined}
disabled={!nextEmail}
/>
</View>
</>
) : null}
)}
<MoreMenuSheet
visible={moreMenuOpen}
@@ -535,6 +637,229 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
);
}
interface EmailPaneProps {
id: string;
email: Email | null;
ensureDetail: (id: string) => Promise<Email | null>;
c: ThemePalette;
styles: ReturnType<typeof makeStyles>;
bottomBarHeight: number;
attachmentPosition: 'beside-sender' | 'below-header';
hideInlineImageAttachments: boolean;
downloadingBlobId: string | null;
onToggleStar: (email: Email) => void;
onPressAttachment: (email: Email, blobId: string, name: string | undefined, type: string | undefined) => void;
onSwipe: (direction: 'prev' | 'next') => void;
onZoomChange: (zoom: { pinching: boolean; zoomed: boolean }) => void;
}
// One swipeable page: the scrollable subject / sender / attachments / body for a
// single message. The pager keeps three of these mounted (prev, current, next)
// so a swipe slides ready content into view. Each pane owns its own vertical
// scroll position and "show all attachments" toggle, and renders directly from
// the email passed to it — neighbours show real content, not a placeholder.
function EmailPane({
id, email, ensureDetail, c, styles, bottomBarHeight, attachmentPosition,
hideInlineImageAttachments, downloadingBlobId, onToggleStar, onPressAttachment, onSwipe,
onZoomChange,
}: EmailPaneProps) {
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
// Freeze the pane's vertical scroll while a pinch is in flight so a two-
// finger zoom can't fling the page; while merely zoomed, vertical scrolling
// stays on — it is how the user pans the (taller) zoomed content vertically.
const [pinching, setPinching] = React.useState(false);
// Each pane owns loading its own message: when the list mounts this page and
// its detail isn't cached yet, fetch it (coalesced upstream). The shared
// cache then re-renders the pane with real content.
React.useEffect(() => {
if (!email) void ensureDetail(id);
}, [id, email, ensureDetail]);
// Detail not fetched yet. Show a skeleton of the pane layout instead of a
// spinner so the transition into real content doesn't jump.
if (!email) {
return <EmailPaneSkeleton styles={styles} />;
}
const from = email.from?.[0];
const starred = !!email.keywords?.$flagged;
const subject = email.subject || '(no subject)';
const renderAttachments = () => {
const all = email.attachments;
if (!all || all.length === 0) return null;
// hideInlineImageAttachments: drop chips for images already shown inline
// in the body (any image attachment with a cid:). Non-image inline
// attachments stay visible because the user wouldn't see them otherwise.
const atts = hideInlineImageAttachments
? all.filter((att) => !(att.cid && (att.type ?? '').startsWith('image/')))
: all;
if (atts.length === 0) return null;
const visible = attachmentsExpanded ? atts : atts.slice(0, 3);
return (
<View style={styles.attachmentsBlock}>
<View style={styles.attachmentsRow}>
{visible.map((att, idx) => {
const isDownloading = downloadingBlobId === att.blobId;
return (
<Pressable
key={att.blobId ?? idx}
style={({ pressed }) => [
styles.attachmentChip,
pressed && styles.attachmentChipPressed,
]}
onPress={() => onPressAttachment(email, att.blobId, att.name, att.type)}
disabled={!!downloadingBlobId}
>
{isDownloading ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Paperclip size={14} color={c.textMuted} />
)}
<Text style={styles.attachmentName} numberOfLines={1}>
{att.name || 'attachment'}
</Text>
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
<Download size={14} color={c.textMuted} />
</Pressable>
);
})}
</View>
{atts.length > 3 && (
<Pressable
onPress={() => setAttachmentsExpanded((v) => !v)}
style={({ pressed }) => [
styles.attachmentsToggle,
pressed && styles.attachmentsTogglePressed,
]}
hitSlop={6}
>
<Text style={styles.attachmentsToggleText}>
{attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`}
</Text>
</Pressable>
)}
</View>
);
};
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={{ paddingBottom: bottomBarHeight + spacing.lg }}
scrollEnabled={!pinching}
>
{/* Subject block */}
<View style={styles.subjectBlock}>
<View style={styles.subjectRow}>
<Text style={styles.subjectText}>{subject}</Text>
<Pressable onPress={() => onToggleStar(email)} hitSlop={8} style={styles.subjectStar}>
<Star
size={18}
color={starred ? c.starred : c.textMuted}
fill={starred ? c.starred : 'transparent'}
/>
</Pressable>
</View>
</View>
{/* Sender info */}
<View style={styles.senderBlock}>
<View style={styles.senderRow}>
<SenderAvatar
name={from?.name}
email={from?.email}
size={componentSizes.avatarMd}
/>
<View style={styles.senderInfo}>
<Text style={styles.senderName} numberOfLines={1}>
{from?.name || from?.email || 'Unknown sender'}
</Text>
{from?.name && from?.email ? (
<Text style={styles.senderEmail} numberOfLines={1}>{from.email}</Text>
) : null}
<Text style={styles.senderRecipients} numberOfLines={1}>
<Text style={styles.senderRecipientsLabel}>to </Text>
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
</Text>
</View>
<View style={styles.senderMeta}>
<Text style={styles.senderDate}>{formatHeaderDate(email.receivedAt)}</Text>
<Text style={styles.senderTime}>
{formatHeaderTime(email.receivedAt)}
{email.size > 0 ? ` · ${formatSize(email.size)}` : ''}
</Text>
</View>
</View>
{attachmentPosition === 'beside-sender' && renderAttachments()}
</View>
{/* Attachments chips (full-width below header) */}
{attachmentPosition === 'below-header' && renderAttachments()}
{/* Calendar invitation (auto-detected .ics) */}
<CalendarInvitationBanner email={email} />
{/* Body */}
<View style={styles.bodyBlock}>
<EmailBodyView
email={email}
senderEmail={from?.email}
onSwipe={onSwipe}
onZoomChange={(z) => { setPinching(z.pinching); onZoomChange(z); }}
/>
</View>
</ScrollView>
);
}
// Placeholder mimicking the pane layout (subject, sender, body lines) shown
// while a message's detail is loading. One pulsing opacity over static bones
// keeps it cheap enough to also sit in the off-screen neighbour panes.
function EmailPaneSkeleton({ styles }: { styles: ReturnType<typeof makeStyles> }) {
const pulse = React.useRef(new Animated.Value(0.55)).current;
React.useEffect(() => {
const loop = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 1, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
Animated.timing(pulse, { toValue: 0.55, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
]),
);
loop.start();
return () => loop.stop();
}, [pulse]);
const bodyLineWidths = ['92%', '100%', '85%', '96%', '60%', '88%', '74%', '40%'] as const;
return (
<Animated.View style={[styles.scroll, { opacity: pulse }]}>
<View style={styles.subjectBlock}>
<View style={[styles.skeletonBone, { height: 20, width: '88%' }]} />
</View>
<View style={styles.senderBlock}>
<View style={styles.senderRow}>
<View style={styles.skeletonAvatar} />
<View style={styles.senderInfo}>
<View style={[styles.skeletonBone, { height: 14, width: '55%' }]} />
<View style={[styles.skeletonBone, { height: 11, width: '70%', marginTop: 6 }]} />
<View style={[styles.skeletonBone, { height: 11, width: '45%', marginTop: 6 }]} />
</View>
<View style={styles.senderMeta}>
<View style={[styles.skeletonBone, { height: 11, width: 86 }]} />
<View style={[styles.skeletonBone, { height: 11, width: 64, marginTop: 6 }]} />
</View>
</View>
</View>
<View style={styles.skeletonBody}>
{bodyLineWidths.map((w, i) => (
<View key={i} style={[styles.skeletonBone, { height: 12, width: w }]} />
))}
</View>
</Animated.View>
);
}
interface MoreMenuSheetProps {
visible: boolean;
onClose: () => void;
@@ -856,6 +1181,8 @@ function makeStyles(c: ThemePalette) {
padding: spacing.lg,
},
errorText: { ...typography.body, color: c.error, textAlign: 'center' },
// Pager: the horizontal page-snapping list fills the area between the bars.
pagerViewport: { flex: 1, backgroundColor: c.background },
scroll: { flex: 1, backgroundColor: c.background },
// Subject block
@@ -871,12 +1198,6 @@ function makeStyles(c: ThemePalette) {
alignItems: 'flex-start',
gap: spacing.sm,
},
subjectMain: {
flex: 1,
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
},
subjectStar: {
paddingTop: 4,
},
@@ -888,18 +1209,6 @@ function makeStyles(c: ThemePalette) {
color: c.text,
letterSpacing: -0.2,
},
subjectMeta: {
alignItems: 'flex-end',
},
subjectDate: {
...typography.caption,
color: c.textSecondary,
},
subjectSize: {
...typography.caption,
color: c.textMuted,
marginTop: 2,
},
// Sender block
senderBlock: {
@@ -932,6 +1241,19 @@ function makeStyles(c: ThemePalette) {
senderRecipientsLabel: {
color: c.textMuted,
},
senderMeta: {
alignItems: 'flex-end',
paddingTop: 1,
},
senderDate: {
...typography.caption,
color: c.textSecondary,
},
senderTime: {
...typography.caption,
color: c.textMuted,
marginTop: 2,
},
// Attachments
attachmentsBlock: {
@@ -990,6 +1312,23 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.background,
},
// Loading skeleton
skeletonBone: {
backgroundColor: c.surfaceHover,
borderRadius: radius.xs,
},
skeletonAvatar: {
width: componentSizes.avatarMd,
height: componentSizes.avatarMd,
borderRadius: radius.full,
backgroundColor: c.surfaceHover,
},
skeletonBody: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.lg,
gap: spacing.sm,
},
// Bottom bar
bottomBar: {
position: 'absolute',
+187 -87
View File
@@ -16,7 +16,7 @@ import {
import {
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo,
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
MoreVertical, Pencil, Share2, Trash2, Upload, X, Download,
MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download,
} from 'lucide-react-native';
// expo-document-picker is loaded lazily on first upload. Its native module
@@ -38,9 +38,8 @@ function loadDocumentPicker(): DocumentPickerModule | null {
}
import {
createFolder, deleteWithDescendants, getAllFileNodes, isDirectChildOfPrefix,
isDirectory, nodeDisplayName, pathToPrefix, PATH_SEP, renameFileNode,
uploadFileNode,
createFolder, deleteFileNodes, getAllFileNodesAcrossAccounts, isCrossAccountId,
isFolder, renameFileNode, supportsSharing, uploadFileNode,
} from '../api/files';
import { downloadAttachment, shareAttachment } from '../lib/email-export';
import type { FileNode } from '../api/types';
@@ -48,9 +47,9 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
import { useColors } from '../theme/colors';
import { useSettingsStore, type FilesViewMode } from '../stores/settings-store';
import Dialog from '../components/Dialog';
import ShareSheet from '../components/files/ShareSheet';
// A file row carries the resolved display name alongside the original
// server-side name (the path-encoded `node.name` field).
// A file row carries the display name alongside the rest of the node.
interface FileRow extends FileNode {
displayName: string;
}
@@ -108,7 +107,10 @@ export default function FilesScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [path, setPath] = useState<string[]>([]);
// Folder navigation stack. Hierarchy is real parentId nesting (#379) — each
// entry is the folder's node id (namespaced "accountId:nodeId" inside a
// shared-with-me subtree) plus its name for the breadcrumb.
const [path, setPath] = useState<{ id: string; name: string }[]>([]);
const [allNodes, setAllNodes] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@@ -124,6 +126,7 @@ export default function FilesScreen() {
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
const [renameValue, setRenameValue] = useState('');
const [actionsTarget, setActionsTarget] = useState<FileRow | null>(null);
const [shareTarget, setShareTarget] = useState<FileRow | null>(null);
const showIcons = useSettingsStore((s) => s.filesShowIcons);
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
@@ -137,8 +140,13 @@ export default function FilesScreen() {
const viewMode: FilesViewMode = viewOverride ?? defaultViewMode;
const selectionMode = selection.size > 0;
const prefix = useMemo(() => pathToPrefix(path), [path]);
const headerName = path.length === 0 ? 'Files' : path[path.length - 1];
const currentParentId = path.length === 0 ? null : path[path.length - 1].id;
// True while browsing inside a folder another principal shared with us.
// Creates/uploads would have to route to the owner's account, which this
// screen doesn't do (matches the webmail's Files app); writes are hidden.
const inSharedSubtree = isCrossAccountId(currentParentId);
const headerName = path.length === 0 ? 'Files' : path[path.length - 1].name;
const sharingEnabled = supportsSharing();
const loadFiles = useCallback(
async (mode: 'initial' | 'refresh' = 'initial') => {
@@ -146,7 +154,9 @@ export default function FilesScreen() {
else setLoading(true);
setError(null);
try {
const nodes = await getAllFileNodes();
// One fetch across all accessible accounts: own files plus nodes other
// principals shared with us (tagged isShared, ids namespaced).
const nodes = await getAllFileNodesAcrossAccounts();
setAllNodes(nodes);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load files');
@@ -162,33 +172,49 @@ export default function FilesScreen() {
void loadFiles();
}, [loadFiles]);
// If we navigated into a folder that no longer exists (it was deleted, or
// we never had it), walk back up until we land on one that does. The check
// looks for a directory node whose serverName matches the encoded path.
// If we navigated into a folder that no longer exists (deleted, or its
// share was revoked), cut the stack back to the deepest ancestor that does.
useEffect(() => {
if (allNodes.length === 0) return;
setPath((prev) => {
let next = prev;
while (next.length > 0) {
const parentPrefix = pathToPrefix(next.slice(0, -1));
const expectedName = parentPrefix + next[next.length - 1];
const exists = allNodes.some((n) => n.name === expectedName && isDirectory(n));
if (exists) break;
next = next.slice(0, -1);
const byId = new Map(allNodes.map((n) => [n.id, n]));
let cut = prev.length;
for (let i = 0; i < prev.length; i++) {
const node = byId.get(prev[i].id);
if (!node || !isFolder(node)) {
cut = i;
break;
}
}
return next.length === prev.length ? prev : next;
return cut === prev.length ? prev : prev.slice(0, cut);
});
}, [allNodes]);
const visibleFiles = useMemo<FileRow[]>(() => {
const rows = allNodes
.filter((n) => isDirectChildOfPrefix(n, prefix))
.map<FileRow>((n) => ({ ...n, displayName: nodeDisplayName(n, prefix) }))
let nodes: FileNode[];
if (currentParentId === null) {
// Root: our own top-level nodes, plus the roots of every shared-with-me
// subtree (a shared node whose parent isn't visible to us).
const idSet = new Set(allNodes.map((n) => n.id));
nodes = allNodes.filter((n) =>
n.isShared
? n.parentId == null || !idSet.has(n.parentId)
: (n.parentId ?? null) === null,
);
} else {
nodes = allNodes.filter((n) => (n.parentId ?? null) === currentParentId);
}
const rows = nodes
.map<FileRow>((n) => ({ ...n, displayName: n.name }))
.filter((r) => showHiddenFiles || !r.displayName.startsWith('.'));
const dir = sortDir === 'desc' ? -1 : 1;
return rows.sort((a, b) => {
const aDir = isDirectory(a) ? 0 : 1;
const bDir = isDirectory(b) ? 0 : 1;
// Own content first, shared-with-me entries after (root only).
const aShared = a.isShared ? 1 : 0;
const bShared = b.isShared ? 1 : 0;
if (aShared !== bShared) return aShared - bShared;
const aDir = isFolder(a) ? 0 : 1;
const bDir = isFolder(b) ? 0 : 1;
if (aDir !== bDir) return aDir - bDir;
switch (sortKey) {
case 'size':
@@ -203,15 +229,18 @@ export default function FilesScreen() {
return a.displayName.localeCompare(b.displayName) * dir;
}
});
}, [allNodes, prefix, showHiddenFiles, sortKey, sortDir]);
}, [allNodes, currentParentId, showHiddenFiles, sortKey, sortDir]);
const clearSelection = useCallback(() => setSelection(new Set()), []);
const toggleSelect = useCallback((id: string) => {
// Shared-with-me rows stay out of multi-select: batch delete routes to our
// own account and would fail (or worse, mismatch) on namespaced ids.
const toggleSelect = useCallback((row: FileRow) => {
if (row.isShared) return;
setSelection((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
if (next.has(row.id)) next.delete(row.id);
else next.add(row.id);
return next;
});
}, []);
@@ -220,7 +249,7 @@ export default function FilesScreen() {
if (!row.blobId) return;
setBusyId(row.id);
try {
await shareAttachment(row.blobId, row.displayName, row.type);
await shareAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId);
} catch (e) {
Alert.alert('Preview failed', e instanceof Error ? e.message : String(e));
} finally {
@@ -232,7 +261,7 @@ export default function FilesScreen() {
if (!row.blobId) return;
setBusyId(row.id);
try {
await downloadAttachment(row.blobId, row.displayName, row.type);
await downloadAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId);
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
} finally {
@@ -243,11 +272,11 @@ export default function FilesScreen() {
const handleRowPress = useCallback(
(row: FileRow) => {
if (selectionMode) {
toggleSelect(row.id);
toggleSelect(row);
return;
}
if (isDirectory(row)) {
setPath((p) => [...p, row.displayName]);
if (isFolder(row)) {
setPath((p) => [...p, { id: row.id, name: row.displayName }]);
return;
}
void previewFile(row);
@@ -256,7 +285,7 @@ export default function FilesScreen() {
);
const handleRowLongPress = useCallback((row: FileRow) => {
toggleSelect(row.id);
toggleSelect(row);
}, [toggleSelect]);
const goBack = useCallback(() => {
@@ -277,19 +306,19 @@ export default function FilesScreen() {
const submitNewFolder = useCallback(async () => {
const name = newFolderName.trim();
if (!name) return;
if (name.includes(PATH_SEP) || name.includes('/')) {
if (name.includes('/')) {
Alert.alert('Invalid name', 'Folder names cannot contain "/".');
return;
}
setNewFolderOpen(false);
setNewFolderName('');
try {
await createFolder(name, path);
await createFolder(name, currentParentId);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Create folder failed', e instanceof Error ? e.message : String(e));
}
}, [newFolderName, path, loadFiles]);
}, [newFolderName, currentParentId, loadFiles]);
const submitRename = useCallback(async () => {
if (!renameTarget) return;
@@ -298,7 +327,7 @@ export default function FilesScreen() {
setRenameTarget(null);
return;
}
if (name.includes(PATH_SEP) || name.includes('/')) {
if (name.includes('/')) {
Alert.alert('Invalid name', 'Names cannot contain "/".');
return;
}
@@ -306,12 +335,12 @@ export default function FilesScreen() {
setRenameTarget(null);
setRenameValue('');
try {
await renameFileNode(target, name, path, allNodes);
await renameFileNode(target.id, name);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Rename failed', e instanceof Error ? e.message : String(e));
}
}, [renameTarget, renameValue, path, allNodes, loadFiles]);
}, [renameTarget, renameValue, loadFiles]);
const startUpload = useCallback(async () => {
if (uploading) return;
@@ -341,7 +370,7 @@ export default function FilesScreen() {
asset.uri,
asset.name,
asset.mimeType || 'application/octet-stream',
path,
currentParentId,
);
await loadFiles('refresh');
} catch (e) {
@@ -349,7 +378,7 @@ export default function FilesScreen() {
} finally {
setUploading(false);
}
}, [uploading, path, loadFiles]);
}, [uploading, currentParentId, loadFiles]);
const requestDelete = useCallback((rows: FileRow[]) => {
if (rows.length === 0) return;
@@ -362,12 +391,13 @@ export default function FilesScreen() {
setConfirmDelete(null);
clearSelection();
try {
await deleteWithDescendants(targets, allNodes);
// The server removes folder descendants (onDestroyRemoveChildren).
await deleteFileNodes(targets.map((t) => t.id));
await loadFiles('refresh');
} catch (e) {
Alert.alert('Delete failed', e instanceof Error ? e.message : String(e));
}
}, [confirmDelete, allNodes, clearSelection, loadFiles]);
}, [confirmDelete, clearSelection, loadFiles]);
const renderHeader = () => {
const canBack = path.length > 0;
@@ -409,27 +439,31 @@ export default function FilesScreen() {
</Text>
</View>
<View style={styles.headerActions}>
<Pressable
onPress={() => setNewFolderOpen(true)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="New folder"
>
<FolderPlus size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => void startUpload()}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="Upload file"
disabled={uploading}
>
{uploading ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<Upload size={20} color={c.text} />
)}
</Pressable>
{!inSharedSubtree ? (
<Pressable
onPress={() => setNewFolderOpen(true)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="New folder"
>
<FolderPlus size={20} color={c.text} />
</Pressable>
) : null}
{!inSharedSubtree ? (
<Pressable
onPress={() => void startUpload()}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="Upload file"
disabled={uploading}
>
{uploading ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<Upload size={20} color={c.text} />
)}
</Pressable>
) : null}
<Pressable
onPress={toggleView}
hitSlop={8}
@@ -442,7 +476,7 @@ export default function FilesScreen() {
</View>
{canBack ? (
<Text style={styles.breadcrumb} numberOfLines={1}>
{['Files', ...path].join(' / ')}
{['Files', ...path.map((p) => p.name)].join(' / ')}
</Text>
) : null}
</View>
@@ -450,13 +484,14 @@ export default function FilesScreen() {
};
const renderRow = (item: FileRow) => {
const isDir = isDirectory(item);
const isDir = isFolder(item);
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
const selected = selection.has(item.id);
const busy = busyId === item.id;
const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0;
return (
<Pressable
onPress={() => handleRowPress(item)}
@@ -470,10 +505,22 @@ export default function FilesScreen() {
>
{showIcons && <Icon size={20} color={tint} />}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
{item.displayName}
</Text>
<View style={styles.fileNameRow}>
<Text style={styles.fileName} numberOfLines={1}>
{item.displayName}
</Text>
{item.isShared ? (
<Share2 size={13} color={c.primary} />
) : sharedOut ? (
<Users size={13} color={c.primary} />
) : null}
</View>
<View style={styles.fileMetaRow}>
{item.isShared && item.accountName ? (
<Text style={styles.fileMeta} numberOfLines={1}>
Shared by {item.accountName}
</Text>
) : null}
{item.size != null && !isDir ? (
<Text style={styles.fileMeta}>{formatFileSize(item.size)}</Text>
) : null}
@@ -501,12 +548,13 @@ export default function FilesScreen() {
};
const renderGridCell = (item: FileRow) => {
const isDir = isDirectory(item);
const isDir = isFolder(item);
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
const selected = selection.has(item.id);
const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0;
return (
<Pressable
onPress={() => handleRowPress(item)}
@@ -523,10 +571,21 @@ export default function FilesScreen() {
) : (
<View style={{ width: 36, height: 36 }} />
)}
<Text style={styles.gridName} numberOfLines={2}>
{item.displayName}
</Text>
{item.size != null && !isDir ? (
<View style={styles.gridNameRow}>
<Text style={styles.gridName} numberOfLines={2}>
{item.displayName}
</Text>
{item.isShared ? (
<Share2 size={12} color={c.primary} />
) : sharedOut ? (
<Users size={12} color={c.primary} />
) : null}
</View>
{item.isShared && item.accountName ? (
<Text style={styles.gridMeta} numberOfLines={1}>
Shared by {item.accountName}
</Text>
) : item.size != null && !isDir ? (
<Text style={styles.gridMeta} numberOfLines={1}>
{formatFileSize(item.size)}
</Text>
@@ -627,6 +686,7 @@ export default function FilesScreen() {
<ActionsSheet
target={actionsTarget}
sharingEnabled={sharingEnabled}
onClose={() => setActionsTarget(null)}
onPreview={(r) => {
setActionsTarget(null);
@@ -636,6 +696,10 @@ export default function FilesScreen() {
setActionsTarget(null);
void downloadFile(r);
}}
onShare={(r) => {
setActionsTarget(null);
setShareTarget(r);
}}
onRename={(r) => {
setActionsTarget(null);
setRenameTarget(r);
@@ -647,6 +711,12 @@ export default function FilesScreen() {
}}
/>
<ShareSheet
node={shareTarget}
onClose={() => setShareTarget(null)}
onChanged={() => void loadFiles('refresh')}
/>
<Dialog
visible={confirmDelete != null}
title={
@@ -722,18 +792,24 @@ function PromptModal(props: PromptModalProps) {
interface ActionsSheetProps {
target: FileRow | null;
sharingEnabled: boolean;
onClose: () => void;
onPreview: (r: FileRow) => void;
onDownload: (r: FileRow) => void;
onShare: (r: FileRow) => void;
onRename: (r: FileRow) => void;
onDelete: (r: FileRow) => void;
}
function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDelete }: ActionsSheetProps) {
function ActionsSheet({ target, sharingEnabled, onClose, onPreview, onDownload, onShare, onRename, onDelete }: ActionsSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
if (!target) return null;
const isDir = isDirectory(target);
const isDir = isFolder(target);
// Owned nodes report full rights (or no myRights at all); shared-with-me
// nodes are managed by their owner, and write ops would have to route to
// the owner's account — so they only get preview/download here.
const canShare = sharingEnabled && !target.isShared && (target.myRights?.mayShare ?? true);
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
@@ -755,14 +831,24 @@ function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDele
<Text style={styles.actionLabel}>Save to device</Text>
</Pressable>
) : null}
<Pressable style={styles.action} onPress={() => onRename(target)}>
<Pencil size={18} color={c.text} />
<Text style={styles.actionLabel}>Rename</Text>
</Pressable>
<Pressable style={styles.action} onPress={() => onDelete(target)}>
<Trash2 size={18} color={c.error} />
<Text style={[styles.actionLabel, { color: c.error }]}>Delete</Text>
</Pressable>
{canShare ? (
<Pressable style={styles.action} onPress={() => onShare(target)}>
<Users size={18} color={c.text} />
<Text style={styles.actionLabel}>Sharing & access</Text>
</Pressable>
) : null}
{!target.isShared ? (
<Pressable style={styles.action} onPress={() => onRename(target)}>
<Pencil size={18} color={c.text} />
<Text style={styles.actionLabel}>Rename</Text>
</Pressable>
) : null}
{!target.isShared ? (
<Pressable style={styles.action} onPress={() => onDelete(target)}>
<Trash2 size={18} color={c.error} />
<Text style={[styles.actionLabel, { color: c.error }]}>Delete</Text>
</Pressable>
) : null}
</View>
</TouchableWithoutFeedback>
</View>
@@ -856,9 +942,15 @@ function makeStyles(c: ThemePalette) {
fileInfo: {
flex: 1,
},
fileNameRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
fileName: {
fontSize: typography.body.fontSize,
color: c.text,
flexShrink: 1,
},
fileMetaRow: {
flexDirection: 'row',
@@ -886,10 +978,18 @@ function makeStyles(c: ThemePalette) {
paddingHorizontal: spacing.xs,
borderRadius: radius.md,
},
gridNameRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
maxWidth: '100%',
},
gridName: {
fontSize: typography.caption.fontSize,
color: c.text,
textAlign: 'center',
flexShrink: 1,
},
gridMeta: {
fontSize: typography.small.fontSize,
+51 -2
View File
@@ -1,11 +1,13 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Constants from 'expo-constants';
import { Eye, EyeOff, X } from 'lucide-react-native';
import { Eye, EyeOff, X, QrCode } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button, Input } from '../components';
import { QrScanModal } from '../components/QrScanModal';
import { parseQrLoginPayload } from '../lib/oauth';
import { useAuthStore } from '../stores/auth-store';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
@@ -21,6 +23,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const styles = React.useMemo(() => makeStyles(c), [c]);
const login = useAuthStore((state) => state.login);
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
const loginViaPairing = useAuthStore((state) => state.loginViaPairing);
const isLoading = useAuthStore((state) => state.isLoading);
const error = useAuthStore((state) => state.error);
const clearError = useAuthStore((state) => state.clearError);
@@ -29,6 +32,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const [showPassword, setShowPassword] = React.useState(false);
const [scannerVisible, setScannerVisible] = React.useState(false);
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
const canHandoff = Boolean(serverUrl.trim());
@@ -65,6 +69,34 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
}
};
const handleScanned = async (data: string) => {
setScannerVisible(false);
const payload = parseQrLoginPayload(data);
if (!payload) {
Alert.alert('Unrecognized QR code', "That QR code isn't a Bulwark Mail sign-in code.");
return;
}
try {
const before = useAuthStore.getState().isAuthenticated;
if (payload.kind === 'connect') {
// Server-bootstrap QR: fill the field and run the normal browser
// handoff so the user still authenticates in the webmail.
setServerUrl(payload.webmailUrl);
await loginViaWebmail(payload.webmailUrl, { addAccount: isAddMode });
} else {
// Cross-device pairing QR: redeem the one-time code for tokens, no
// browser round-trip needed.
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
}
const after = useAuthStore.getState().isAuthenticated;
if ((after && !before) || (isAddMode && after)) {
onLogin?.();
}
} catch {
// Store state already contains the user-facing error.
}
};
const updateField = React.useCallback(
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
if (useAuthStore.getState().error) {
@@ -182,6 +214,15 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
>
Sign in via webmail
</Button>
<Button
variant="ghost"
size="md"
onPress={() => setScannerVisible(true)}
disabled={isLoading}
icon={<QrCode size={18} color={c.text} />}
>
Scan QR code
</Button>
</View>
</View>
@@ -192,6 +233,14 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
</View>
</View>
</KeyboardAvoidingView>
<QrScanModal
visible={scannerVisible}
onClose={() => setScannerVisible(false)}
onScanned={(data) => {
void handleScanned(data);
}}
/>
</SafeAreaView>
);
}
+95 -5
View File
@@ -7,6 +7,7 @@ vi.mock('../../api/calendar', () => ({
createEvent: vi.fn(),
updateEvent: vi.fn(),
deleteEvents: vi.fn(),
setDefaultCalendar: vi.fn(),
}));
vi.mock('@react-native-async-storage/async-storage', () => ({
@@ -37,6 +38,7 @@ const mockGetEvents = calendarApi.getEvents as ReturnType<typeof vi.fn>;
const mockCreateEvent = calendarApi.createEvent as ReturnType<typeof vi.fn>;
const mockUpdateEvent = calendarApi.updateEvent as ReturnType<typeof vi.fn>;
const mockDeleteEvents = calendarApi.deleteEvents as ReturnType<typeof vi.fn>;
const mockSetDefaultCalendar = calendarApi.setDefaultCalendar as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
@@ -99,6 +101,70 @@ describe('calendar-store', () => {
expect(useCalendarStore.getState().events).toEqual([]);
expect(mockGetEvents).not.toHaveBeenCalled();
});
it('namespaces shared/group event ids by owning account and keeps the real id in originalId', async () => {
// A shared calendar lives in another account; its event id ("ev1") can
// collide with one of the user's own events, so the store must namespace
// it. Mirrors webmail's mapServerEventToStoreEvent.
useCalendarStore.setState({
calendars: [
{ id: 'cal-1', name: 'Personal' } as any,
{ id: 'shared-cal', name: 'Team', accountId: 'acc-2', isShared: true } as any,
],
});
mockQueryEvents.mockImplementation(async (_ids: string[], _a: string, _b: string, accountId?: string) =>
accountId === 'acc-2' ? ['ev1'] : [],
);
mockGetEvents.mockImplementation(async (_ids: string[], accountId?: string) =>
accountId === 'acc-2'
? [{ id: 'ev1', title: 'Standup', start: '2026-03-15T10:00:00', calendarIds: { 'shared-cal': true } }]
: [],
);
await useCalendarStore.getState().fetchEvents(['cal-1', 'shared-cal'], '2026-03-01', '2026-03-31');
const stored = useCalendarStore.getState().events;
expect(stored).toHaveLength(1);
expect(stored[0].id).toBe('acc-2:ev1');
expect(stored[0].originalId).toBe('ev1');
expect(stored[0].accountId).toBe('acc-2');
});
it('routes queries by account when own and shared calendars share a raw id', async () => {
// The user's own "default" calendar and a family group's "default"
// calendar collide on the raw JMAP id. The shared one is namespaced
// (`acc-2:default`, originalId "default"); the query must use the raw id
// against acc-2, and the returned event's calendarIds must be remapped to
// the namespaced store id so it isn't hidden with the personal calendar.
useCalendarStore.setState({
calendars: [
{ id: 'default', name: 'Personal' } as any,
{ id: 'acc-2:default', originalId: 'default', name: 'Family', accountId: 'acc-2', isShared: true } as any,
],
});
const queried: Record<string, string[]> = {};
mockQueryEvents.mockImplementation(async (ids: string[], _a: string, _b: string, accountId?: string) => {
queried[accountId ?? 'primary'] = ids;
return accountId === 'acc-2' ? ['fam1'] : [];
});
mockGetEvents.mockImplementation(async (_ids: string[], accountId?: string) =>
accountId === 'acc-2'
? [{ id: 'fam1', title: 'Soccer', start: '2026-03-20T15:00:00', calendarIds: { default: true } }]
: [],
);
await useCalendarStore.getState().fetchEvents(['default', 'acc-2:default'], '2026-03-01', '2026-03-31');
// The shared account is queried with the raw calendar id, not the prefix.
expect(queried['acc-2']).toEqual(['default']);
const ev = useCalendarStore.getState().events[0];
expect(ev.calendarIds).toEqual({ 'acc-2:default': true });
// Hiding the personal "default" must not hide the family event.
useCalendarStore.setState({ hiddenCalendarIds: ['default'] });
const visible = selectVisibleEvents(useCalendarStore.getState());
expect(visible.map((e) => e.id)).toContain('acc-2:fam1');
});
});
describe('ensureRange', () => {
@@ -110,7 +176,8 @@ describe('calendar-store', () => {
await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31');
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31');
// 4th arg is the owning account — undefined for primary-account calendars.
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined);
});
it('should skip refetch when loaded range covers requested', async () => {
@@ -134,7 +201,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().ensureRange('2026-04-01', '2026-04-30');
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-04-30');
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-04-30', undefined);
});
it('should fetch calendars first when none are loaded', async () => {
@@ -144,7 +211,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31');
expect(mockGetCalendars).toHaveBeenCalled();
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31');
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined);
});
});
@@ -273,8 +340,31 @@ describe('calendar-store', () => {
);
// The third arg is the iMIP scheduling flag — undefined here since the
// event has no participants to notify.
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined);
// event has no participants to notify. The fourth is the owning
// account — undefined for primary-account events.
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined, undefined);
});
});
describe('setDefaultCalendar', () => {
it('flips isDefault to the chosen calendar within the same account', async () => {
useCalendarStore.setState({
calendars: [
{ id: 'cal-1', name: 'A', isDefault: true } as any,
{ id: 'cal-2', name: 'B' } as any,
{ id: 'cal-3', name: 'Shared', isShared: true, accountId: 'acc-2', isDefault: true } as any,
],
});
mockSetDefaultCalendar.mockResolvedValue(undefined);
await useCalendarStore.getState().setDefaultCalendar('cal-2');
expect(mockSetDefaultCalendar).toHaveBeenCalledWith('cal-2', undefined);
const calendars = useCalendarStore.getState().calendars;
expect(calendars.find((c) => c.id === 'cal-1')?.isDefault).toBe(false);
expect(calendars.find((c) => c.id === 'cal-2')?.isDefault).toBe(true);
// Defaults in other accounts are untouched.
expect(calendars.find((c) => c.id === 'cal-3')?.isDefault).toBe(true);
});
});
+72 -26
View File
@@ -7,7 +7,7 @@ import { useContactsStore } from './contacts-store';
import { useCalendarStore } from './calendar-store';
import { useFilterStore } from './filter-store';
import { generateAccountId } from '../lib/account-utils';
import { runWebmailHandoff, HandoffCancelledError } from '../lib/oauth';
import { runWebmailHandoff, redeemPairingCode, HandoffCancelledError, type HandoffResult } from '../lib/oauth';
import {
teardownPushNotifications,
teardownPushNotificationsForAccount,
@@ -41,6 +41,7 @@ export interface AuthState {
login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>;
logout: () => Promise<void>;
logoutAll: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
@@ -97,6 +98,43 @@ function refetchFeatureStores(): void {
}
}
// Shared tail of the OAuth sign-in flows (browser handoff and cross-device QR
// pairing both end here). Bootstraps a JMAP session from the token bundle,
// registers the account, and flips the store to connected. Throws on failure
// so the caller can surface a flow-specific error.
async function completeOAuthHandoff(
set: (partial: Partial<AuthState>) => void,
get: () => AuthState,
result: Extract<HandoffResult, { flow: 'oauth' }>,
opts?: { addAccount?: boolean },
): Promise<void> {
if (opts?.addAccount && get().isAuthenticated) {
jmapClient.reset();
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
}
const { session, username, accountId } = await jmapClient.connectWithOAuth(
result.serverUrl,
result.tokens,
);
const accountStore = useAccountStore.getState();
accountStore.addAccount({
serverUrl: result.serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
});
accountStore.setActiveAccount(accountId);
useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
}
function applyConnectedState(
set: (partial: Partial<AuthState>) => void,
session: JMAPSession,
@@ -200,31 +238,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// token bundle. Bootstrap the JMAP session with Bearer auth and let
// ensure/forceRefreshToken keep it alive going forward.
try {
if (opts?.addAccount && get().isAuthenticated) {
jmapClient.reset();
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
}
const { session, username, accountId } = await jmapClient.connectWithOAuth(
result.serverUrl,
result.tokens,
);
const accountStore = useAccountStore.getState();
accountStore.addAccount({
serverUrl: result.serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
});
accountStore.setActiveAccount(accountId);
useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
await completeOAuthHandoff(set, get, result, opts);
} catch (err) {
const message =
err instanceof AuthenticationError
@@ -237,6 +251,38 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
loginViaPairing: async (webmailUrl, code, opts) => {
set({ isLoading: true, error: null });
let result;
try {
result = await redeemPairingCode(webmailUrl, code);
} catch (err) {
const message = err instanceof Error ? err.message : 'Pairing failed';
set({ isLoading: false, error: message });
throw err;
}
// redeemPairingCode only ever yields the OAuth flow, but guard anyway so a
// future server change can't silently mis-route credentials here.
if (result.flow !== 'oauth') {
set({ isLoading: false, error: 'Unexpected pairing response' });
throw new Error('Unexpected pairing response');
}
try {
await completeOAuthHandoff(set, get, result, opts);
} catch (err) {
const message =
err instanceof AuthenticationError
? 'Authentication rejected by server'
: err instanceof Error
? err.message
: 'Pairing sign-in failed';
set({ isLoading: false, error: message });
throw err;
}
},
logout: async () => {
const accountStore = useAccountStore.getState();
const currentId = get().activeAccountId;
+126 -18
View File
@@ -12,6 +12,7 @@ import {
batchCreateEvents as apiBatchCreateEvents,
rsvpEvent as apiRsvpEvent,
createCalendar as apiCreateCalendar,
setDefaultCalendar as apiSetDefaultCalendar,
} from '../api/calendar';
import { jmapClient } from '../api/jmap-client';
import { expandRecurringEvents } from '../lib/recurrence-expansion';
@@ -24,6 +25,37 @@ function hasSchedulingParticipants(event?: Partial<CalendarEvent> | null): boole
const HIDDEN_CALENDARS_STORAGE_KEY = 'webmail:calendar:hidden:v1';
// Map a server CalendarEvent onto its store representation. For events from a
// shared/group account the JMAP id is only unique within that account, so we
// namespace `id` (stashing the real id in originalId) and remap the event's
// `calendarIds` from raw server ids onto the namespaced store calendar ids so
// visibility filtering and per-calendar colour lookup line up. Events from the
// primary account are returned unchanged. Mirrors webmail's
// mapServerEventToStoreEvent.
function mapServerEventToStoreEvent(
event: CalendarEvent,
calendars: Calendar[],
accountId?: string,
): CalendarEvent {
if (!accountId) return event;
const mapped: Record<string, boolean> = {};
for (const [calId, included] of Object.entries(event.calendarIds || {})) {
const cal = calendars.find(
(c) => (c.originalId || c.id) === calId && c.accountId === accountId,
);
mapped[cal?.id || calId] = included;
}
return {
...event,
id: `${accountId}:${event.id}`,
originalId: event.id,
originalCalendarIds: event.calendarIds,
calendarIds: Object.keys(mapped).length > 0 ? mapped : event.calendarIds,
accountId,
isShared: true,
};
}
export interface LoadedRange {
after: string;
before: string;
@@ -57,6 +89,7 @@ export interface CalendarState {
) => Promise<void>;
importEvents: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
createCalendar: (name: string, color?: string) => Promise<Calendar>;
setDefaultCalendar: (id: string) => Promise<void>;
// Tasks
createTask: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void>;
updateTask: (id: string, changes: Partial<CalendarEvent>) => Promise<void>;
@@ -131,8 +164,37 @@ export const useCalendarStore = create<CalendarState>()(
if (!jmapClient.isConnected) return;
set({ loading: true, error: null });
try {
const ids = (await queryEvents(calendarIds, after, before)) ?? [];
const raw = ids.length > 0 ? ((await fetchEvents(ids)) ?? []) : [];
// Group the requested calendars by owning account: the primary account
// (calendars without an accountId tag) plus one group per shared
// account, since CalendarEvent/query is scoped to a single account. The
// incoming ids are store ids (shared calendars are namespaced
// `${accountId}:${id}`); map them back to the raw server ids the query
// filter expects.
const calendars = get().calendars;
const byId = new Map(calendars.map((c) => [c.id, c]));
const groups = new Map<string | undefined, string[]>();
for (const id of calendarIds) {
const cal = byId.get(id);
const accountId = cal?.accountId;
const serverId = cal?.originalId || id;
const group = groups.get(accountId);
if (group) group.push(serverId);
else groups.set(accountId, [serverId]);
}
if (groups.size === 0) groups.set(undefined, []);
const raw: CalendarEvent[] = [];
for (const [accountId, ids] of groups) {
try {
const eventIds = (await queryEvents(ids, after, before, accountId)) ?? [];
if (eventIds.length === 0) continue;
const fetched = (await fetchEvents(eventIds, accountId)) ?? [];
raw.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId)));
} catch (err) {
// A failing shared account must not hide the user's own events.
if (!accountId) throw err;
}
}
// Stalwart returns both Events and Tasks from CalendarEvent/query. Events
// have a `start`; tasks are surfaced separately so they don't pollute the
// grid (they're shown in the task list with their due date instead).
@@ -176,12 +238,19 @@ export const useCalendarStore = create<CalendarState>()(
handleStateChange: async (change) => {
if (!jmapClient.isConnected) return;
const accountId = jmapClient.accountId;
const accountChanges = change.changed?.[accountId];
if (!accountChanges) return;
const calendarChanged = 'Calendar' in accountChanges;
const eventChanged = 'CalendarEvent' in accountChanges;
// Watch the primary account plus every shared account we show calendars
// from, so edits the owner makes to a shared calendar refresh the view.
const known = new Set<string>([jmapClient.accountId]);
for (const cal of get().calendars) {
if (cal.accountId) known.add(cal.accountId);
}
let calendarChanged = false;
let eventChanged = false;
for (const [accountId, types] of Object.entries(change.changed ?? {})) {
if (!known.has(accountId)) continue;
if ('Calendar' in types) calendarChanged = true;
if ('CalendarEvent' in types) eventChanged = true;
}
if (!calendarChanged && !eventChanged) return;
if (calendarChanged) {
@@ -193,12 +262,22 @@ export const useCalendarStore = create<CalendarState>()(
},
createEvent: async (event, calendarId) => {
// Shared calendars live in the owner's account — route the create there,
// and against the calendar's raw server id (calendarId is the namespaced
// store id for shared calendars).
const calendars = get().calendars;
const cal = calendars.find((c) => c.id === calendarId);
const accountId = cal?.accountId;
const created = await apiCreateEvent(
event,
calendarId,
cal?.originalId || calendarId,
hasSchedulingParticipants(event) ? true : undefined,
accountId,
);
set({ events: [...get().events, created] });
// Map the same way fetchEvents does so the optimistic insert doesn't
// collide with the user's own events and stays visible under the right
// calendar filter.
set({ events: [...get().events, mapServerEventToStoreEvent(created, calendars, accountId)] });
return created;
},
@@ -210,7 +289,7 @@ export const useCalendarStore = create<CalendarState>()(
// carry participants.
const schedule =
hasSchedulingParticipants(changes) || hasSchedulingParticipants(storeEvent);
await apiUpdateEvent(realId, changes, schedule ? true : undefined);
await apiUpdateEvent(realId, changes, schedule ? true : undefined, storeEvent?.accountId);
set({
events: get().events.map((e) => (e.id === id ? { ...e, ...changes } : e)),
});
@@ -222,6 +301,7 @@ export const useCalendarStore = create<CalendarState>()(
await apiDeleteEvents(
[realId],
hasSchedulingParticipants(storeEvent) ? true : undefined,
storeEvent?.accountId,
);
set({ events: get().events.filter((e) => e.id !== id) });
},
@@ -232,7 +312,7 @@ export const useCalendarStore = create<CalendarState>()(
}
const storeEvent = get().events.find((e) => e.id === eventId);
const realId = storeEvent?.originalId || eventId;
await apiRsvpEvent(realId, participantId, status, replyTo);
await apiRsvpEvent(realId, participantId, status, replyTo, storeEvent?.accountId);
set({
events: get().events.map((e) => {
if (e.id !== eventId || !e.participants?.[participantId]) return e;
@@ -249,20 +329,26 @@ export const useCalendarStore = create<CalendarState>()(
importEvents: async (events, calendarId) => {
if (events.length === 0) return 0;
// Shared calendars live in the owner's account and carry a namespaced
// store id — resolve the raw server id + owning account so dedup and
// create target the right place.
const cal = get().calendars.find((c) => c.id === calendarId);
const accountId = cal?.accountId;
const serverCalendarId = cal?.originalId || calendarId;
// Stalwart enforces UID uniqueness across calendars. Skip events whose UID
// already exists; create the rest. (We don't attempt cross-calendar linking
// on mobile — a duplicate is simply skipped.)
let toCreate = events;
try {
const existingIds = await queryEvents([], '', '');
const existing = existingIds.length > 0 ? await fetchEvents(existingIds) : [];
const existingIds = await queryEvents([], '', '', accountId);
const existing = existingIds.length > 0 ? await fetchEvents(existingIds, accountId) : [];
const seenUids = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]);
toCreate = events.filter((e) => !e.uid || !seenUids.has(e.uid));
} catch {
// Couldn't dedupe — proceed and let the server reject genuine dupes.
}
if (toCreate.length === 0) return 0;
const count = await apiBatchCreateEvents(toCreate, calendarId);
const count = await apiBatchCreateEvents(toCreate, serverCalendarId, accountId);
await get().refresh();
return count;
},
@@ -273,15 +359,37 @@ export const useCalendarStore = create<CalendarState>()(
return created;
},
setDefaultCalendar: async (id) => {
const cal = get().calendars.find((c) => c.id === id);
await apiSetDefaultCalendar(cal?.originalId || id, cal?.accountId);
set({
calendars: get().calendars.map((c) => {
if (c.id === id) return { ...c, isDefault: true };
// Only one default per account — clear the flag on siblings within
// the same account scope.
if (c.isDefault && (c.accountId ?? null) === (cal?.accountId ?? null)) {
return { ...c, isDefault: false };
}
return c;
}),
});
},
createTask: async (task, calendarId) => {
await apiCreateEvent({ ...task, '@type': 'Task' }, calendarId);
const cal = get().calendars.find((c) => c.id === calendarId);
await apiCreateEvent(
{ ...task, '@type': 'Task' },
cal?.originalId || calendarId,
undefined,
cal?.accountId,
);
await get().refresh();
},
updateTask: async (id, changes) => {
const task = get().tasks.find((t) => t.id === id);
const realId = task?.originalId || id;
await apiUpdateEvent(realId, changes);
await apiUpdateEvent(realId, changes, undefined, task?.accountId);
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
},
@@ -298,7 +406,7 @@ export const useCalendarStore = create<CalendarState>()(
deleteTask: async (id) => {
const task = get().tasks.find((t) => t.id === id);
const realId = task?.originalId || id;
await apiDeleteEvents([realId]);
await apiDeleteEvents([realId], undefined, task?.accountId);
set({ tasks: get().tasks.filter((t) => t.id !== id) });
},
+75
View File
@@ -21,6 +21,55 @@ export type SwipeAction =
| 'pin'
| 'move';
export type SwipeMode = 'instant' | 'reveal';
// Actions that can be placed in the email reader's bottom quick-action bar.
// The first three are the reply family (the default bar); any reply-family
// action the user removes from the bar is relocated to the top toolbar so it
// stays reachable.
export type QuickAction =
| 'reply'
| 'replyAll'
| 'forward'
| 'delete'
| 'archive'
| 'markUnread'
| 'star'
| 'move'
| 'spam'
| 'tag';
export const REPLY_QUICK_ACTIONS: QuickAction[] = ['reply', 'replyAll', 'forward'];
export const ALL_QUICK_ACTIONS: QuickAction[] = [
'reply',
'replyAll',
'forward',
'delete',
'archive',
'markUnread',
'star',
'move',
'spam',
'tag',
];
// The reader bottom bar always shows exactly three quick actions (between the
// prev/next nav buttons). Coerce any persisted value into three unique, valid
// ids, backfilling from the reply-family default when entries are missing.
export function normalizeBottomQuickActions(value: unknown): QuickAction[] {
const out: QuickAction[] = [];
if (Array.isArray(value)) {
for (const a of value) {
if (ALL_QUICK_ACTIONS.includes(a as QuickAction) && !out.includes(a as QuickAction)) {
out.push(a as QuickAction);
}
}
}
for (const d of REPLY_QUICK_ACTIONS) {
if (out.length >= 3) break;
if (!out.includes(d)) out.push(d);
}
return out.slice(0, 3);
}
export type ArchiveMode = 'single' | 'year' | 'month';
export type CalendarView = 'month' | 'week' | 'day' | 'agenda';
export type FirstDayOfWeek = 0 | 1;
@@ -103,6 +152,10 @@ interface PersistedSettings {
swipeRightAction: SwipeAction;
swipeMode: SwipeMode;
// Email reader's bottom quick-action bar (3 slots). Defaults to the reply
// family; reply-family actions removed from here move to the top toolbar.
bottomQuickActions: QuickAction[];
// Archive
archiveMode: ArchiveMode;
@@ -116,6 +169,10 @@ interface PersistedSettings {
showBirthdayCalendar: boolean;
enableCalendarTasks: boolean;
showTasksOnCalendar: boolean;
// Per-viewer color overrides for shared calendars, keyed by
// sharedCalendarColorKey(). Lets the user recolor calendars shared with
// them without changing the owner's color (parity with webmail #345).
sharedCalendarColors: Record<string, string>;
// Files
filesFolderLayout: FilesFolderLayout;
@@ -210,6 +267,8 @@ const DEFAULT_PERSISTED: PersistedSettings = {
swipeRightAction: 'read',
swipeMode: 'instant',
bottomQuickActions: ['reply', 'replyAll', 'forward'],
archiveMode: 'single',
calendarDefaultView: 'month',
@@ -221,6 +280,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
showBirthdayCalendar: true,
enableCalendarTasks: false,
showTasksOnCalendar: true,
sharedCalendarColors: {},
filesFolderLayout: 'inline',
filesDefaultViewMode: 'list',
@@ -298,6 +358,10 @@ export interface SettingsState extends PersistedSettings {
removeTrustedSender: (email: string) => void;
isSenderTrusted: (email: string) => boolean;
// Shared-calendar color overrides
setSharedCalendarColor: (key: string, color: string) => void;
removeSharedCalendarColor: (key: string) => void;
// Sidebar apps
addSidebarApp: (app: Omit<SidebarApp, 'id'>) => void;
updateSidebarApp: (id: string, updates: Partial<Omit<SidebarApp, 'id'>>) => void;
@@ -420,6 +484,17 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
return get().trustedSenders.includes(normalized);
},
setSharedCalendarColor: (key, color) => {
set({ sharedCalendarColors: { ...get().sharedCalendarColors, [key]: color } });
persist(snapshot(get()));
},
removeSharedCalendarColor: (key) => {
const { [key]: _removed, ...rest } = get().sharedCalendarColors;
set({ sharedCalendarColors: rest });
persist(snapshot(get()));
},
addSidebarApp: (app) => {
const id = `app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
set({ sidebarApps: [...get().sidebarApps, { ...app, id }] });