Compare commits

..
11 Commits
26 changed files with 3255 additions and 693 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.43
0.1.48
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.43",
"version": "0.1.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.43",
"version": "0.1.48",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.43",
"version": "0.1.48",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+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'));
+106 -14
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,81 @@ 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 ?? [];
return list.map((cal) => ({ ...cal, 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 +162,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: {
@@ -136,8 +208,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 +223,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 +239,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 +300,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();
}
+54
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 {
@@ -467,6 +470,10 @@ 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;
}
export interface CalendarRights {
@@ -492,6 +499,17 @@ 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: 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 +523,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 +590,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;
+219 -11
View File
@@ -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 {
@@ -177,6 +186,30 @@ const HEIGHT_REPORTER = `
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
@@ -189,18 +222,18 @@ const HEIGHT_REPORTER = `
// (the widest unbreakable element) rather than a previously-scaled box.
w.style.width = '100%';
var content = w.scrollWidth;
if (content > avail + 1) {
var scale = avail / content;
w.style.width = content + 'px';
w.style.transform = 'scale(' + scale + ')';
} else {
w.style.transform = 'none';
}
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;
fitToWidth(w);
// 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();
@@ -270,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);
@@ -423,12 +606,30 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
const applyInversion =
!!rawHtml && renderAsDark && !hasNativeDarkMode(rawHtml);
const injectedJs = React.useMemo(
() => (applyInversion ? DARK_REINVERT_SCRIPT + HEIGHT_REPORTER : HEIGHT_REPORTER),
() =>
(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));
};
@@ -473,6 +674,10 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
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. The
// re-inversion pass guards itself (__rnDarkInvertDone) so running it
@@ -482,6 +687,9 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
}, 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}
+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,
+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,
},
});
}
@@ -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');
});
});
+52 -1
View File
@@ -435,12 +435,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 {
+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.
+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;
}
+51 -3
View File
@@ -52,8 +52,11 @@ 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,
} from '../lib/calendar-utils';
import { buildReplyTo } from '../lib/calendar-invitation';
@@ -117,6 +120,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 +160,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 +176,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),
@@ -491,6 +530,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 +551,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}
/>
+417 -340
View File
@@ -1,8 +1,9 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
Modal, useWindowDimensions, Animated, Easing, Alert, PanResponder,
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 {
@@ -35,8 +36,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',
});
}
@@ -64,7 +70,6 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
// 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 [activeSubject, setActiveSubject] = React.useState(route.params.subject ?? '');
const insets = useSafeAreaInsets();
const { width: windowWidth } = useWindowDimensions();
const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
@@ -80,121 +85,85 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
const [email, setEmail] = React.useState<Email | null>(null);
const [loading, setLoading] = React.useState(true);
// True only while a Prev/Next/swipe transition is waiting on an
// not-yet-prefetched neighbour. Drives a lightweight overlay spinner so the
// brief gap shows feedback instead of a blank pane — without unmounting the
// (still-animating) slide pane the way the full-screen `loading` state would.
const [switching, setSwitching] = React.useState(false);
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. Lets us show a
// neighbour instantly (no spinner) when switching, and prefetch adjacent
// messages so a swipe lands on ready content.
// 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],
);
// --- Swipe / slide navigation between adjacent emails -------------------
// translateX drives both the finger-follow drag and the slide-out/slide-in
// transition. Refs keep the (memoised once) PanResponder reading the latest
// neighbours / dimensions without being recreated every render.
const translateX = React.useRef(new Animated.Value(0)).current;
const scrollRef = React.useRef<ScrollView>(null);
const animatingRef = React.useRef(false);
const prevEmailRef = React.useRef(prevEmail);
const nextEmailRef = React.useRef(nextEmail);
const windowWidthRef = React.useRef(windowWidth);
prevEmailRef.current = prevEmail;
nextEmailRef.current = nextEmail;
windowWidthRef.current = windowWidth;
// --- 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)),
);
const switchEmail = React.useCallback((direction: 'prev' | 'next') => {
const target = direction === 'next' ? nextEmailRef.current : prevEmailRef.current;
if (!target || animatingRef.current) return;
animatingRef.current = true;
const w = windowWidthRef.current;
const out = direction === 'next' ? -w : w;
// 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]);
// Make sure the target's detail is in the cache *before* we swap it into
// view. That way the load effect takes its no-spinner cached path and the
// full-screen `loading` state never fires — which is what unmounted the
// slide pane mid-animation and produced the blank-then-jump flash.
// Neighbours are usually prefetched, so this resolves immediately.
const ready: Promise<unknown> = detailCache.has(target.id)
? Promise.resolve()
: getEmailDetail(target.id, jmapAccountId)
.then((e) => { detailCache.set(target.id, e); })
.catch(() => { /* fall back to the load effect's own fetch */ });
// 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]);
// Swap content + slide the new message in from the opposite edge.
const swapAndSlideIn = () => {
setSwitching(false);
setActiveEmailId(target.id);
setActiveSubject(target.subject ?? '');
scrollRef.current?.scrollTo({ y: 0, animated: false });
translateX.setValue(-out);
Animated.timing(translateX, {
toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true,
}).start(() => { animatingRef.current = false; });
};
// 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]);
// Slide the current message the rest of the way out, then swap once the
// target is ready. If it isn't cached yet, show the overlay spinner during
// the (usually brief) wait rather than leaving an empty pane on screen.
Animated.timing(translateX, {
toValue: out, duration: 170, easing: Easing.out(Easing.cubic), useNativeDriver: true,
}).start(() => {
if (detailCache.has(target.id)) {
swapAndSlideIn();
} else {
setSwitching(true);
void ready.then(swapAndSlideIn);
}
});
}, [translateX, detailCache, getEmailDetail, jmapAccountId]);
const switchEmailRef = React.useRef(switchEmail);
switchEmailRef.current = switchEmail;
// 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]);
const panResponder = React.useRef(
PanResponder.create({
onMoveShouldSetPanResponder: (_, g) => {
if (animatingRef.current) return false;
if (Math.abs(g.dx) < 10) return false;
// Only claim clearly-horizontal drags so the body's vertical scroll
// (and link taps) keep working.
return Math.abs(g.dx) > Math.abs(g.dy) * 1.5;
},
onPanResponderMove: (_, g) => {
let dx = g.dx;
// Rubber-band when there's no neighbour to reach in that direction.
if ((dx > 0 && !prevEmailRef.current) || (dx < 0 && !nextEmailRef.current)) {
dx *= 0.25;
}
translateX.setValue(dx);
},
onPanResponderRelease: (_, g) => {
const w = windowWidthRef.current;
const threshold = Math.min(w * 0.28, 120);
const fast = Math.abs(g.vx) > 0.5;
if ((g.dx <= -threshold || (fast && g.vx < 0)) && nextEmailRef.current) {
switchEmailRef.current('next');
} else if ((g.dx >= threshold || (fast && g.vx > 0)) && prevEmailRef.current) {
switchEmailRef.current('prev');
} else {
Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
}
},
onPanResponderTerminate: () => {
Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
},
onPanResponderTerminationRequest: () => true,
}),
).current;
// 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);
@@ -213,14 +182,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
);
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));
@@ -228,66 +197,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);
@@ -296,60 +207,34 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
React.useEffect(() => {
let cancelled = false;
let readTimer: ReturnType<typeof setTimeout> | null = null;
// If we already have this message cached (e.g. it was prefetched as a
// neighbour), show it immediately — no spinner — and refresh in the
// background. Otherwise fall back to the loading state.
const cached = detailCache.get(activeEmailId);
if (cached) {
setEmail(cached);
setLoading(false);
setError(null);
} else {
setLoading(true);
setError(null);
}
// 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(activeEmailId, jmapAccountId);
if (cancelled) return;
detailCache.set(activeEmailId, fetched);
setEmail(fetched);
if (fetched && !fetched.keywords?.$seen) {
if (markAsReadDelay > 0) {
readTimer = setTimeout(() => {
if (!cancelled) void markRead(activeEmailId, jmapAccountId);
}, markAsReadDelay);
} else {
void markRead(activeEmailId, 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) {
// Only surface the error if we have nothing to show; a cached copy is
// better than an error screen when the refresh fails.
if (!cancelled && !detailCache.has(activeEmailId)) {
setError(e instanceof Error ? e.message : 'Failed to load email');
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
if (readTimer) clearTimeout(readTimer);
};
}, [activeEmailId, jmapAccountId, getEmailDetail, markRead, markAsReadDelay, detailCache]);
// Prefetch the adjacent messages so a swipe / Prev-Next lands on ready
// content instead of a spinner. Best-effort and silent.
React.useEffect(() => {
for (const neighbour of [prevEmail, nextEmail]) {
if (neighbour && !detailCache.has(neighbour.id)) {
getEmailDetail(neighbour.id, jmapAccountId)
.then((e) => detailCache.set(neighbour.id, e))
.catch(() => { /* ignore — prefetch is best-effort */ });
}
}
}, [prevEmail, nextEmail, getEmailDetail, jmapAccountId, detailCache]);
}, [activeEmailId, ensureDetail, markRead, markAsReadDelay, jmapAccountId, detailCache]);
const starred = !!email?.keywords?.$flagged;
const unread = !!email && !email.keywords?.$seen;
@@ -368,14 +253,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
);
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
const updateLocalKeywords = (next: Record<string, boolean>) => {
setEmail((prev) => {
if (!prev) return prev;
const updated = { ...prev, keywords: next };
// Keep the cached copy in sync so swiping back shows the toggled state.
detailCache.set(prev.id, updated);
return updated;
});
// 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) => {
@@ -383,28 +267,32 @@ 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);
}
};
@@ -536,8 +424,6 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
},
};
const subject = email?.subject ?? activeSubject ?? '(no subject)';
const from = email?.from?.[0];
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
// Show extras in priority order. Each toolbar button is ~64px wide (icon+label+padding);
@@ -608,98 +494,59 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
</View>
</View>
{loading && !email ? (
<View style={styles.centered}>
<ActivityIndicator color={c.primary} />
</View>
) : error && !email ? (
{error && !email ? (
<View style={styles.centered}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : email ? (
) : (
<>
<Animated.View
style={[styles.slidePane, { transform: [{ translateX }] }]}
{...panResponder.panHandlers}
>
<ScrollView
ref={scrollRef}
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>
</Animated.View>
{/* Transient spinner while a switch waits on an un-prefetched
neighbour — sits above the (slid-out) pane, not in place of it. */}
{switching && (
<View style={styles.switchOverlay} pointerEvents="none">
<ActivityIndicator color={c.primary} />
</View>
)}
)}
/>
{/* 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 ? () => switchEmail('prev') : undefined}
onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined}
disabled={!prevEmail}
/>
{bottomActions.map((id) => {
@@ -717,12 +564,12 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<BottomBarButton
icon={<ChevronRight size={20} color={c.textMuted} />}
label="Next"
onPress={nextEmail ? () => switchEmail('next') : undefined}
onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined}
disabled={!nextEmail}
/>
</View>
</>
) : null}
)}
<MoreMenuSheet
visible={moreMenuOpen}
@@ -781,6 +628,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;
@@ -1102,13 +1172,8 @@ function makeStyles(c: ThemePalette) {
padding: spacing.lg,
},
errorText: { ...typography.body, color: c.error, textAlign: 'center' },
switchOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: c.background,
},
slidePane: { flex: 1, backgroundColor: c.background },
// 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
@@ -1124,12 +1189,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,
},
@@ -1141,18 +1200,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: {
@@ -1185,6 +1232,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: {
@@ -1243,6 +1303,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,
+31 -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();
@@ -110,7 +112,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 +137,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 +147,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 +276,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);
});
});
+67 -14
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';
@@ -57,6 +58,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 +133,31 @@ 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.
const calendars = get().calendars;
const groups = new Map<string | undefined, string[]>();
for (const id of calendarIds) {
const accountId = calendars.find((c) => c.id === id)?.accountId;
const group = groups.get(accountId);
if (group) group.push(id);
else groups.set(accountId, [id]);
}
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(...(accountId ? fetched.map((e) => ({ ...e, accountId })) : fetched));
} 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 +201,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 +225,15 @@ export const useCalendarStore = create<CalendarState>()(
},
createEvent: async (event, calendarId) => {
// Shared calendars live in the owner's account — route the create there.
const accountId = get().calendars.find((c) => c.id === calendarId)?.accountId;
const created = await apiCreateEvent(
event,
calendarId,
hasSchedulingParticipants(event) ? true : undefined,
accountId,
);
set({ events: [...get().events, created] });
set({ events: [...get().events, accountId ? { ...created, accountId } : created] });
return created;
},
@@ -210,7 +245,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 +257,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 +268,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;
@@ -273,15 +309,32 @@ export const useCalendarStore = create<CalendarState>()(
return created;
},
setDefaultCalendar: async (id) => {
const cal = get().calendars.find((c) => c.id === id);
await apiSetDefaultCalendar(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 accountId = get().calendars.find((c) => c.id === calendarId)?.accountId;
await apiCreateEvent({ ...task, '@type': 'Task' }, calendarId, undefined, 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 +351,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) });
},
+20
View File
@@ -169,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;
@@ -276,6 +280,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
showBirthdayCalendar: true,
enableCalendarTasks: false,
showTasksOnCalendar: true,
sharedCalendarColors: {},
filesFolderLayout: 'inline',
filesDefaultViewMode: 'list',
@@ -353,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;
@@ -475,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 }] });