Merge branch 'main' into feat/stalwart-1.0

This commit is contained in:
Linus Rath
2026-10-29 01:30:03 +01:00
782 changed files with 202548 additions and 95749 deletions
+138
View File
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxCallsInRequest: vi.fn(() => 16),
getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500),
},
}));
import { jmapClient } from '../jmap-client';
import { saveDraftVersion, type OutgoingAttachment, type OutgoingEmail } from '../email';
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
/**
* A tiny server with Stalwart's blob semantics, as measured on 0.16.22 (2026-10-14):
* an email's part blobIds are derived from that email and stop resolving the
* moment it is destroyed ("blobId … does not exist on this server"); creating
* an email copies the referenced bytes into new parts with new blobIds.
*/
function fakeStalwart() {
let seq = 0;
const emails = new Map<string, Array<{ blobId: string; name: string; size: number; cid?: string; type: string }>>();
const live = (blobId: string) => [...emails.values()].some((parts) => parts.some((p) => p.blobId === blobId));
const seed = (id: string, parts: OutgoingAttachment[]) => {
emails.set(id, parts.map((p) => ({ blobId: p.blobId, name: p.name, size: p.size ?? 0, cid: p.cid, type: p.type })));
};
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, any>, string]>) => {
const out: Array<[string, unknown, string]> = [];
let createdId: string | undefined;
for (const [method, args, callId] of calls) {
if (method === 'Email/set' && args.create) {
const create = args.create.draft;
const dead = (create.attachments ?? []).find((a: { blobId: string }) => !live(a.blobId));
if (dead) {
out.push(['Email/set', { notCreated: { draft: {
type: 'blobNotFound', description: `blobId ${dead.blobId} does not exist on this server.`,
} } }, callId]);
continue;
}
createdId = `e${++seq}`;
emails.set(createdId, (create.attachments ?? []).map((a: OutgoingAttachment, i: number) => ({
blobId: `${createdId}-part${i}`, name: a.name, size: a.size ?? 0, cid: a.cid ? `<${a.cid}>` : undefined, type: a.type,
})));
out.push(['Email/set', { created: { draft: { id: createdId } } }, callId]);
} else if (method === 'Email/set' && args.destroy) {
for (const id of args.destroy) emails.delete(id);
out.push(['Email/set', { destroyed: args.destroy }, callId]);
} else if (method === 'Email/get') {
if (!createdId || args.ids?.[0] !== '#draft') {
out.push(['error', { type: 'invalidResultReference' }, callId]);
continue;
}
out.push(['Email/get', { list: [{ id: createdId, attachments: emails.get(createdId) }] }, callId]);
}
}
return { methodResponses: out };
});
return { seed, emails };
}
const IMG_A: OutgoingAttachment = { blobId: 'd0-part-a', name: 'schema-a.png', size: 136574, type: 'image/png', disposition: 'inline', cid: 'a@undust.co' };
const IMG_B: OutgoingAttachment = { blobId: 'd0-part-b', name: 'schema-b.png', size: 175155, type: 'image/png', disposition: 'inline', cid: 'b@undust.co' };
const draftWith = (attachments: OutgoingAttachment[]): OutgoingEmail => ({
from: [{ email: 'me@undust.co' }],
to: [{ email: 'test@example.invalid' }],
subject: 'RE: draft with two inline images',
htmlBody: '<p>x <img src="cid:a@undust.co"> <img src="cid:b@undust.co"></p>',
textBody: 'x',
attachments,
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('saveDraftVersion', () => {
it('reads the new version\'s parts back in the same request, by creation id', async () => {
fakeStalwart().seed('d0', [IMG_A]);
const saved = await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
const calls = mockRequest.mock.calls[0][0];
expect(calls.map((c: [string]) => c[0])).toEqual(['Email/set', 'Email/get']);
expect(calls[1][1].ids).toEqual(['#draft']);
expect(saved.id).toBe('e1');
expect(saved.attachments.map((a) => a.blobId)).toEqual(['e1-part0']);
});
it('destroys the previous version only after the new one exists', async () => {
fakeStalwart().seed('d0', [IMG_A]);
await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
const destroyCall = mockRequest.mock.calls[1][0][0];
expect(destroyCall[0]).toBe('Email/set');
expect(destroyCall[1].destroy).toEqual(['d0']);
});
it('still returns the id when the read-back fails', async () => {
mockRequest.mockResolvedValueOnce({ methodResponses: [
['Email/set', { created: { draft: { id: 'e9' } } }, '0'],
['error', { type: 'serverFail' }, '1'],
] });
const saved = await saveDraftVersion(draftWith([]), 'drafts');
expect(saved).toEqual({ id: 'e9', attachments: [] });
});
});
describe('re-opened draft with inline images: save, save again, send', () => {
// What ComposeScreen does: buildOutgoing maps each held blobId through the
// state, saveDraftOnce rebases it on the parts read back.
async function editSession(rebase: boolean) {
const server = fakeStalwart();
server.seed('d0', [IMG_A, IMG_B]); // the draft as deposited (IMAP APPEND or an earlier save)
let state = initialDraftPartBlobState();
let draftId = 'd0';
const outgoing = () => draftWith([IMG_A, IMG_B].map((a) => ({ ...a, blobId: liveBlobId(state, a.blobId) })));
for (let i = 0; i < 2; i++) {
const sent = outgoing();
const saved = await saveDraftVersion(sent, 'drafts', draftId);
draftId = saved.id;
if (rebase) state = rebaseDraftPartBlobs(state, sent.attachments ?? [], saved.attachments);
}
return { server, draftId };
}
it('reproduces the bug without the rebase: the second save references the destroyed draft\'s parts', async () => {
await expect(editSession(false)).rejects.toThrow('blobId d0-part-a does not exist on this server.');
});
it('keeps every save (and so the send, which builds the same attachments) on live blobs', async () => {
const { server, draftId } = await editSession(true);
expect([...server.emails.keys()]).toEqual([draftId]);
expect(server.emails.get(draftId)!.map((p) => p.name)).toEqual(['schema-a.png', 'schema-b.png']);
});
});
@@ -450,6 +450,40 @@ describe('JMAPClient hardening', () => {
expect(client.authHeader).toBe(`Basic ${btoa('c@example.com:pw')}`);
});
// On iOS the notification extension refreshes OAuth tokens too and writes
// the new bundle back. Refreshing again with the refresh token in memory
// would spend a token the server has already replaced.
it('retries a 401 with a newer stored token instead of refreshing again', async () => {
const SecureStore = await import('expo-secure-store');
const client = new JMAPClient();
mockFetch([{ status: 200, json: SESSION }]);
await client.connectWithOAuth('https://mail.example.com', {
accessToken: 'old',
refreshToken: 'r1',
expiresAt: Date.now() + 3_600_000,
tokenEndpoint: 'https://mail.example.com/auth/token',
clientId: 'c',
});
(SecureStore.getItemAsync as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify({
serverUrl: 'https://mail.example.com',
username: 'user@example.com',
password: '',
accessToken: 'from-extension',
refreshToken: 'r2',
expiresAt: Date.now() + 7_200_000,
tokenEndpoint: 'https://mail.example.com/auth/token',
clientId: 'c',
}));
const fetch = mockFetch([
{ status: 401 },
{ status: 200, json: { methodResponses: [['Mailbox/get', {}, '0']] } },
]);
await client.request([['Mailbox/get', {}, '0']]);
expect(mockRefresh).not.toHaveBeenCalled();
expect((fetch.mock.calls[1][1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer from-extension' });
(SecureStore.getItemAsync as ReturnType<typeof vi.fn>).mockReset();
});
it('updatePassword rewrites the stored credential', async () => {
const SecureStore = await import('expo-secure-store');
const client = await connected();
+68 -2
View File
@@ -8,7 +8,7 @@ vi.mock('expo-secure-store', () => ({
}));
import * as SecureStore from 'expo-secure-store';
import { JMAPClient, AuthenticationError, RateLimitError, SessionSupersededError, parseHoldLimit } from '../jmap-client';
import { JMAPClient, AuthenticationError, InvalidTokenError, RateLimitError, SessionSupersededError, parseHoldLimit } from '../jmap-client';
import type { JMAPSession } from '../types';
const MOCK_SESSION: JMAPSession = {
@@ -113,8 +113,10 @@ describe('JMAPClient', () => {
});
describe('connectWithToken', () => {
const TOKEN_SESSION = { ...MOCK_SESSION, username: 'user@example.com' };
it('should use Bearer auth', async () => {
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
await client.connectWithToken('https://mail.example.com', 'my-token');
@@ -128,6 +130,70 @@ describe('JMAPClient', () => {
);
expect(client.isConnected).toBe(true);
});
it('names the account after the session and remembers the token', async () => {
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
const { username, accountId } = await client.connectWithToken('https://mail.example.com/', 'my-token');
expect(username).toBe('user@example.com');
expect(accountId).toBe('user@example.com@mail.example.com');
const [key, stored] = vi.mocked(SecureStore.setItemAsync).mock.calls.at(-1)!;
expect(key).toContain('user_example.com');
expect(JSON.parse(stored)).toMatchObject({
serverUrl: 'https://mail.example.com',
username: 'user@example.com',
accessToken: 'my-token',
});
expect(client.usesPastedToken).toBe(true);
});
it('refuses a token the server rejects', async () => {
global.fetch = mockFetch([{ status: 401 }]) as any;
await expect(client.connectWithToken('https://mail.example.com', 'bad')).rejects.toThrow(InvalidTokenError);
expect(client.isConnected).toBe(false);
});
it('refuses a session that does not name the account', async () => {
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
await expect(client.connectWithToken('https://mail.example.com', 'my-token')).rejects.toThrow('did not name the account');
expect(client.isConnected).toBe(false);
});
it('keeps a download host the server runs on another HTTPS origin', async () => {
global.fetch = mockFetch([{
status: 200,
json: {
...TOKEN_SESSION,
apiUrl: 'https://api.fastmail.com/jmap/api/',
downloadUrl: 'https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}',
uploadUrl: 'https://api.fastmail.com/jmap/upload/{accountId}/',
},
}]) as any;
const { session } = await client.connectWithToken('https://api.fastmail.com', 'my-token');
expect(session.downloadUrl).toBe('https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}');
expect(session.uploadUrl).toBe('https://api.fastmail.com/jmap/upload/{accountId}/');
});
it('still moves self-reported internal hosts onto the server address', async () => {
global.fetch = mockFetch([{
status: 200,
json: {
...TOKEN_SESSION,
apiUrl: 'http://localhost:8080/jmap/',
downloadUrl: 'http://localhost:8080/download/{accountId}/{blobId}/{name}?type={type}',
},
}]) as any;
const { session } = await client.connectWithToken('https://mail.example.com', 'my-token');
expect(session.apiUrl).toBe('https://mail.example.com/jmap/');
expect(session.downloadUrl).toBe('https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}');
});
});
describe('request', () => {
+43
View File
@@ -22,6 +22,8 @@ import {
getFullEmails,
getThreadsHeaders,
createDraft,
saveDraftVersion,
destroyEmails,
sendEmail,
markAsSpam,
undoSpam,
@@ -33,6 +35,9 @@ import {
EMAIL_FULL_PROPERTIES,
} from '../email';
import { getIdentities } from '../identity';
import { uploadBytes } from '../blob';
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
import type { OutgoingAttachment } from '../email';
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
@@ -192,6 +197,44 @@ live('live Stalwart', () => {
await destroyEmails([id]);
}, 60_000);
it('keeps a re-opened draft with an inline image saveable across saves (#849 part blobs)', async () => {
// A 1x1 PNG uploaded once; D0 stands for the draft as it sits on the server.
const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0));
const up = await uploadBytes(png, 'image/png');
const base = {
from: [{ email: USER }],
to: [{ email: 'test@example.invalid' }],
subject: '[TEST bulwark-blob — supprimable]',
htmlBody: '<p>live <img src="cid:img1@live.test"></p>',
textBody: 'live',
};
const img = (blobId: string): OutgoingAttachment => ({
blobId, type: 'image/png', name: 'img1.png', size: png.byteLength, disposition: 'inline', cid: 'img1@live.test',
});
const d0 = await saveDraftVersion({ ...base, attachments: [img(up.blobId)] }, drafts);
const created = [d0.id];
try {
// Re-open D0: the composer holds D0's part blobId.
const seeded = d0.attachments[0].blobId;
let state = initialDraftPartBlobState();
let draftId = d0.id;
for (let i = 0; i < 2; i++) {
const sent = [img(liveBlobId(state, seeded))];
const saved = await saveDraftVersion({ ...base, attachments: sent }, drafts, draftId);
created.push(saved.id);
draftId = saved.id;
state = rebaseDraftPartBlobs(state, sent, saved.attachments);
}
// Without the rebase the seeded part blob is gone with D0.
await expect(saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts))
.rejects.toThrow(/does not exist|blobNotFound/);
const [last] = await getFullEmails([draftId]);
expect(last.attachments?.[0]?.cid).toContain('img1@live.test');
} finally {
await destroyEmails(created).catch(() => undefined);
}
}, 90_000);
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
const identities = await getIdentities();
expect(identities.length).toBeGreaterThan(0);
+48 -5
View File
@@ -1867,31 +1867,64 @@ export async function sendEmail(
};
}
/** A saved draft version and the attachment parts the server built for it. */
export interface SavedDraftVersion {
id: string;
/**
* Attachment and inline parts of the new version, with their blobIds. A
* part's blobId only lives as long as this version: re-opened drafts must
* send these (not the previous version's) on their next save or send.
* Empty when the server did not answer the read-back.
*/
attachments: Attachment[];
}
const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition'];
/**
* Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId`
* is given it is destroyed only AFTER the replacement was created (#849): a
* combined create+destroy would delete the last good copy when the create
* fails (e.g. blobNotFound on a re-opened draft's attachments). Returns the
* new draft id.
* fails (e.g. blobNotFound on a re-opened draft's attachments). The new
* version's attachment parts are read back in the same request, so the
* composer can re-point its draft-part blobIds before the old version (and
* its part blobs) is destroyed.
*/
export async function createDraft(
export async function saveDraftVersion(
email: OutgoingEmail,
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
): Promise<string> {
): Promise<SavedDraftVersion> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const emailCreate = buildEmailCreate(email);
emailCreate.mailboxIds = { [draftsMailboxId]: true };
emailCreate.keywords = { $seen: true, $draft: true };
const res = await jmapClient.request([
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
// `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not
// resolve a result reference into an Email/set `created` map
// (invalidResultReference), but it does resolve the creation id.
['Email/get', {
accountId,
ids: ['#draft'],
properties: ['id', 'attachments'],
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
}, '1'],
]);
const body = requireMethodResult(res, '0', 'Email/set');
const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined;
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft');
const id = body.created?.draft?.id as string | undefined;
if (!id) throw new Error('Draft save returned no id');
let attachments: Attachment[] = [];
try {
const got = requireMethodResult(res, '1', 'Email/get');
const saved = (got.list as Array<{ id: string; attachments?: Attachment[] }> | undefined)?.find((e) => e.id === id);
attachments = (saved?.attachments ?? []).filter((a) => !!a.blobId);
} catch (err) {
console.warn('[email] could not read back the saved draft parts:', err);
}
if (previousDraftId && previousDraftId !== id) {
try {
await destroyEmails([previousDraftId], accountId);
@@ -1899,7 +1932,17 @@ export async function createDraft(
console.warn('[email] failed to destroy previous draft version:', err);
}
}
return id;
return { id, attachments };
}
/** {@link saveDraftVersion} for callers that only need the new draft id. */
export async function createDraft(
email: OutgoingEmail,
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
): Promise<string> {
return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id;
}
export interface ScheduledEmail {
+115 -31
View File
@@ -15,6 +15,7 @@ import {
type OAuthTokenSource,
} from '../lib/oauth';
import { FirstTouchGate } from './first-touch-gate';
import { writeSharedSecret, moveToSharedGroup } from '../lib/ios-push-shared';
import { beginOwnWrite, recordOwnEmailWrites } from './own-writes';
// Refresh OAuth access tokens this many ms before they actually expire so
@@ -40,7 +41,7 @@ const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
const CREDENTIALS_PREFIX = 'jmap_credentials__';
// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/".
function credentialsKey(accountId: string): string {
export function credentialsKey(accountId: string): string {
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
}
@@ -194,6 +195,12 @@ export class JMAPClient {
return !!this.credentials?.accessToken;
}
// A pasted access token, as opposed to an OAuth bundle (which always names
// the token endpoint it renews at).
get usesPastedToken(): boolean {
return !!this.credentials?.accessToken && !this.credentials.tokenEndpoint;
}
get isConnected(): boolean {
return this.session !== null && this._accountId !== null;
}
@@ -283,10 +290,7 @@ export class JMAPClient {
this.firstTouchGate.reset();
const accountId = generateAccountId(username, baseUrl);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(credentials),
);
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
return session;
}
@@ -323,19 +327,50 @@ export class JMAPClient {
return session;
}
async connectWithToken(serverUrl: string, accessToken: string): Promise<JMAPSession> {
/**
* Access-token login (an API token the user pasted, e.g. from Fastmail).
* The token is the whole credential: it names the account, so the username
* comes from the session, and with no refresh token it lasts until the
* provider expires or revokes it.
*/
async connectWithToken(
serverUrl: string,
accessToken: string,
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
const baseUrl = serverUrl.replace(/\/+$/, '');
const epoch = this.detachSession();
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
const fetched = await this.fetchSession(baseUrl);
let fetched: JMAPSession;
try {
fetched = await this.fetchSession(baseUrl);
} catch (err) {
// A newer account change owns the credentials now.
this.assertCurrentSession(epoch);
this.credentials = null;
if (err instanceof AuthenticationError || (err instanceof Error && /^Session discovery failed: 403\b/.test(err.message))) {
throw new InvalidTokenError();
}
throw err;
}
this.assertCurrentSession(epoch);
const session = this.rewriteSessionUrls(fetched, baseUrl);
this.session = session;
this._accountId = this.resolveAccountId(session);
this.firstTouchGate.reset();
return session;
const username = session.username;
if (!username) {
this.reset();
throw new Error('The server did not name the account');
}
const credentials: StoredCredentials = { ...this.credentials!, username };
this.credentials = credentials;
const accountId = generateAccountId(username, baseUrl);
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
return { session, username, accountId };
}
// OAuth login via webmail handoff. The webmail did the OAuth dance against
@@ -379,10 +414,7 @@ export class JMAPClient {
this.credentials = credentials;
const accountId = generateAccountId(username, baseUrl);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(credentials),
);
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
return { session, username, accountId };
}
@@ -421,16 +453,39 @@ export class JMAPClient {
clientId: next.clientId,
};
if (inUse) this.credentials = updated;
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(updated),
);
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(updated));
if (!inUse) return;
for (const l of this.tokenRefreshListeners) {
try { l(); } catch { /* ignore */ }
}
}
/**
* Take the stored OAuth tokens when they are newer than the ones in memory:
* on iOS the notification extension refreshes them too and writes the new
* bundle back. True when the in-memory tokens changed.
*/
private async adoptStoredTokens(): Promise<boolean> {
const current = this.credentials;
if (!current?.accessToken || !current.refreshToken) return false;
const accountId = generateAccountId(current.username, current.serverUrl);
const stored = await this.getStoredCredentials(accountId).catch(() => null);
// Another account may have been opened meanwhile.
if (this.credentials !== current) return false;
if (!stored?.accessToken || stored.accessToken === current.accessToken) return false;
if ((stored.expiresAt ?? 0) <= (current.expiresAt ?? 0)) return false;
this.credentials = {
...current,
accessToken: stored.accessToken,
refreshToken: stored.refreshToken ?? current.refreshToken,
expiresAt: stored.expiresAt,
};
for (const l of this.tokenRefreshListeners) {
try { l(); } catch { /* ignore */ }
}
return true;
}
private currentOAuthTokens(): { credentials: StoredCredentials; tokens: OAuthTokens } | null {
const credentials = this.credentials;
if (
@@ -473,14 +528,21 @@ export class JMAPClient {
// consumers of `authHeader` (SSE stream, file downloads) can make sure the
// header they capture is not about to expire.
async ensureFreshToken(): Promise<void> {
const current = this.currentOAuthTokens();
let current = this.currentOAuthTokens();
if (!current) return;
const { credentials, tokens } = current;
if (tokens.expiresAt == null) return;
if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
const { expiresAt } = current.tokens;
if (expiresAt == null) return;
if (expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
// Another process may have refreshed already (the iOS notification
// extension); use its tokens rather than spend a refresh token the
// server has replaced.
if (await this.adoptStoredTokens()) {
current = this.currentOAuthTokens();
if (!current || (current.tokens.expiresAt != null && current.tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS)) return;
}
try {
const next = await refreshOAuthAccessToken(tokens);
await this.persistRefreshedTokens(credentials, next);
const next = await refreshOAuthAccessToken(current.tokens);
await this.persistRefreshedTokens(current.credentials, next);
} catch {
// Surface as AuthenticationError on the next 401; refresh may be
// temporarily failing (network) and the reactive retry path catches it.
@@ -493,6 +555,10 @@ export class JMAPClient {
// the account instead of evicting it (webmail 1.7.6 "keep the session when
// the auth server is briefly unreachable").
async forceRefreshToken(): Promise<boolean> {
if (!this.currentOAuthTokens()) return false;
// A newer token in storage (refreshed by the iOS notification extension)
// is worth a retry before refreshing again.
if (await this.adoptStoredTokens()) return true;
const current = this.currentOAuthTokens();
if (!current) return false;
try {
@@ -519,10 +585,7 @@ export class JMAPClient {
this.credentials.username,
this.credentials.serverUrl,
);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
}
// Legacy single-slot restore - kept for backward-compat tests. New code
@@ -553,6 +616,9 @@ export class JMAPClient {
// A newer load may have put its credentials in by now.
this.assertCurrentSession(epoch);
if (!stored) return false;
// iOS: credentials saved before the notification extension existed move
// to the keychain group it can read (a no-op elsewhere and afterwards).
void moveToSharedGroup(credentialsKey(accountId)).catch(() => undefined);
let creds: StoredCredentials;
try {
@@ -658,7 +724,7 @@ export class JMAPClient {
const creds: StoredCredentials = JSON.parse(stored);
// Re-save under the per-account key before dropping the legacy entry
const accountId = generateAccountId(creds.username, creds.serverUrl);
await SecureStore.setItemAsync(credentialsKey(accountId), stored);
await writeSharedSecret(credentialsKey(accountId), stored);
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
return creds;
} catch {
@@ -677,16 +743,26 @@ export class JMAPClient {
// URL polyfill mutates inputs (appends trailing slashes, normalises
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
// before the caller has a chance to substitute values into them.
//
// A URL on another HTTPS origin than the server's own apiUrl is one it
// deliberately hosts elsewhere (Fastmail serves downloads from a separate
// domain) and is kept; everything else moves onto the origin we connected to.
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
const serverOrigin = extractOrigin(serverUrl);
const reportedOrigin = session.apiUrl ? extractOrigin(session.apiUrl) : null;
const rewrite = (url: string | undefined): string | undefined =>
rewriteSessionUrl(url, serverOrigin);
const rewriteHosted = (url: string | undefined): string | undefined => {
const origin = url ? extractOrigin(url) : null;
const hostedElsewhere = !!origin && !!reportedOrigin && origin !== reportedOrigin && /^https:/i.test(origin);
return hostedElsewhere ? url : rewrite(url);
};
return {
...session,
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl,
downloadUrl: rewrite(session.downloadUrl) ?? session.downloadUrl,
uploadUrl: rewrite(session.uploadUrl) ?? session.uploadUrl,
eventSourceUrl: rewrite(session.eventSourceUrl) ?? session.eventSourceUrl,
downloadUrl: rewriteHosted(session.downloadUrl) ?? session.downloadUrl,
uploadUrl: rewriteHosted(session.uploadUrl) ?? session.uploadUrl,
eventSourceUrl: rewriteHosted(session.eventSourceUrl) ?? session.eventSourceUrl,
};
}
@@ -1256,7 +1332,7 @@ export class JMAPClient {
}
async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise<void> {
await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds));
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(creds));
}
// ── Blob Download ─────────────────────────────────────
@@ -1353,6 +1429,14 @@ export class AuthenticationError extends Error {
}
}
// The server refused a pasted access token (wrong, expired or revoked).
export class InvalidTokenError extends Error {
constructor() {
super('Invalid access token');
this.name = 'InvalidTokenError';
}
}
// The server wants a second factor before it will hand out a session.
export class TotpRequiredError extends AuthenticationError {
constructor() {
+36
View File
@@ -1,21 +1,57 @@
import { useEffect } from 'react';
import { Platform } from 'react-native';
import { useEmailStore } from '../stores/email-store';
import { useSettingsStore } from '../stores/settings-store';
import { useLocaleStore } from '../stores/locale-store';
import { useAuthStore } from '../stores/auth-store';
import { appBadgeCount, setAppBadge } from '../lib/app-badge';
import { jmapClient } from '../api/jmap-client';
import { writeIosPushSettings } from '../lib/ios-push-shared';
import { shareAccountWithExtension } from '../lib/push-notifications';
/**
* Keeps the app icon badge on the active account's inbox unread count (see
* `lib/app-badge`). Mounted with the signed-in UI; unmounting means the last
* account signed out, so the badge is cleared then. Renders nothing.
*
* On iOS it also tells the notification extension (plugins/ios-push) which
* account the badge follows and the few texts it writes itself, so a push
* that arrives while the app is closed updates the badge the same way.
*/
export function AppIconBadge() {
const enabled = useSettingsStore((s) => s.appIconUnreadBadge);
const count = useEmailStore((s) => appBadgeCount(s.mailboxes, enabled));
const activeAccountId = useAuthStore((s) => s.activeAccountId);
// The JMAP session comes up after the account is known (offline start).
const haveSession = useAuthStore((s) => s.session != null);
const t = useLocaleStore((s) => s.t);
useEffect(() => {
void setAppBadge(count);
}, [count]);
useEffect(() => {
if (Platform.OS !== 'ios') return;
let jmapAccountId: string | null = null;
try {
jmapAccountId = jmapClient.accountId;
} catch {
jmapAccountId = null;
}
// The extension learns how to reach the active account as soon as it is
// signed in, so a push for it shows sender and subject even before (or
// without) this device's own push setup, e.g. after a reinstall.
if (activeAccountId) void shareAccountWithExtension(activeAccountId, jmapAccountId);
void writeIosPushSettings({
badge: enabled,
activeAccountId: jmapAccountId,
strings: {
noSubject: t('push.ios.no_subject', '(no subject)'),
more: t('push.ios.more_messages', 'and {count} more'),
},
}).catch(() => undefined);
}, [enabled, activeAccountId, haveSession, t]);
useEffect(() => () => { void setAppBadge(0); }, []);
return null;
+17 -11
View File
@@ -1,20 +1,25 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { radius, typography, type ThemePalette } from '../theme/tokens';
import { radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface BadgeProps {
/** Sentence case ("Not on this server"); badges are never upper case. */
children: string;
variant?: 'default' | 'primary' | 'destructive' | 'outline' | 'muted';
style?: ViewStyle;
}
/**
* A small status pill. The default is the webmail's outline badge: hairline
* border, muted 12px text. The tinted variants keep the same size.
*/
export default function Badge({ children, variant = 'default', style }: BadgeProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.base, styles[`bg_${variant}` as const], style]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
{children}
</Text>
</View>
@@ -25,23 +30,24 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'transparent',
paddingHorizontal: 8,
paddingVertical: 2,
paddingVertical: 1,
alignSelf: 'flex-start',
},
text: { ...typography.caption, fontWeight: '500' },
text: { fontSize: 12, lineHeight: 16 },
bg_default: { backgroundColor: c.surface },
bg_primary: { backgroundColor: c.primaryBg },
bg_default: { borderColor: c.border },
bg_outline: { borderColor: c.border },
bg_primary: { backgroundColor: c.accent },
bg_destructive: { backgroundColor: c.errorBg },
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
bg_muted: { backgroundColor: c.muted },
text_default: { color: c.text },
text_primary: { color: c.primary },
text_default: { color: c.mutedForeground },
text_outline: { color: c.mutedForeground },
text_primary: { color: c.accentForeground },
text_destructive: { color: c.error },
text_outline: { color: c.text },
text_muted: { color: c.mutedForeground },
});
}
+51 -25
View File
@@ -1,9 +1,19 @@
import React from 'react';
import { Pressable, Text, StyleSheet, ViewStyle, ActivityIndicator } from 'react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { spacing, radius, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
/**
* The webmail's standard-theme buttons (components/ui/button.tsx):
* - default: bg-primary text-primary-foreground
* - secondary: bg-muted text-foreground
* - outline: border, transparent
* - ghost: no border or fill until pressed
* - destructive: red fill, white text
* - destructiveOutline: red border and text, for a reset in a settings row
* All 36 dp tall (lg 44 for a page's main action), radius 6, 14px medium.
*/
type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'outline' | 'destructive' | 'destructiveOutline';
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
interface ButtonProps {
@@ -15,8 +25,13 @@ interface ButtonProps {
loading?: boolean;
icon?: React.ReactNode;
style?: ViewStyle;
/** Needed for an icon-only button; a text button reads its text. */
accessibilityLabel?: string;
}
// Extra touch area so every size reaches a 48 dp target.
const HIT_SLOP: Record<ButtonSize, number> = { sm: 6, md: 6, lg: 2, icon: 4 };
export default function Button({
variant = 'default',
size = 'md',
@@ -26,10 +41,14 @@ export default function Button({
loading = false,
icon,
style,
accessibilityLabel,
}: ButtonProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isDisabled = disabled || loading;
const spinnerColor = variant === 'default'
? c.primaryForeground
: variant === 'destructive' ? '#ffffff' : c.text;
return (
<Pressable
@@ -43,17 +62,18 @@ export default function Button({
]}
onPress={onPress}
disabled={isDisabled}
hitSlop={HIT_SLOP[size]}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ disabled: isDisabled, busy: loading }}
>
{loading ? (
<ActivityIndicator
size="small"
color={variant === 'default' ? c.primaryForeground : c.primary}
/>
<ActivityIndicator size="small" color={spinnerColor} />
) : (
<>
{icon}
{typeof children === 'string' ? (
<Text style={[styles.text, styles[`text_${variant}` as const], isDisabled && styles.textDisabled]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
{children}
</Text>
) : (
@@ -69,30 +89,36 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
borderRadius: radius.md, gap: spacing.sm,
borderRadius: radius.sm, gap: spacing.sm,
},
text: { ...typography.bodyMedium },
text: { fontSize: 14, fontWeight: '500', lineHeight: 20 },
disabled: { opacity: 0.5 },
textDisabled: { opacity: 0.5 },
size_sm: { height: componentSizes.buttonSm, paddingHorizontal: spacing.md },
size_md: { height: componentSizes.buttonMd, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
size_lg: { height: componentSizes.buttonLg, paddingHorizontal: spacing.xxxl },
// minHeight, so a larger system font grows the button instead of clipping.
size_sm: { minHeight: componentSizes.buttonSm, paddingHorizontal: spacing.md },
size_md: { minHeight: componentSizes.buttonSm, paddingHorizontal: 14 },
size_lg: { minHeight: componentSizes.buttonLg, paddingHorizontal: spacing.xxl },
size_icon: { height: componentSizes.buttonMd, width: componentSizes.buttonMd, paddingHorizontal: 0 },
bg_default: { backgroundColor: c.primary, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
bg_ghost: { backgroundColor: 'transparent' },
bg_outline: { backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
bg_destructive: { backgroundColor: c.error, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
bg_default: { backgroundColor: c.primary },
bg_secondary: { backgroundColor: c.muted },
bg_ghost: { backgroundColor: 'transparent' },
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
bg_destructive: { backgroundColor: c.error },
bg_destructiveOutline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.error },
pressed_default: { backgroundColor: c.primaryDark, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
pressed_ghost: { backgroundColor: c.accent },
pressed_outline: { backgroundColor: c.accent },
pressed_destructive: { opacity: 0.9 },
pressed_default: { opacity: 0.9 },
pressed_secondary: { opacity: 0.8 },
pressed_ghost: { backgroundColor: c.muted },
pressed_outline: { backgroundColor: c.muted },
pressed_destructive: { opacity: 0.9 },
pressed_destructiveOutline: { backgroundColor: c.errorBg },
text_default: { color: c.primaryForeground },
text_ghost: { color: c.text },
text_outline: { color: c.text },
text_destructive: { color: c.errorForeground },
text_default: { color: c.primaryForeground },
text_secondary: { color: c.text },
text_ghost: { color: c.text },
text_outline: { color: c.text },
text_destructive: { color: '#ffffff' },
text_destructiveOutline: { color: c.error },
});
}
+8 -5
View File
@@ -1,10 +1,11 @@
import React from 'react';
import { View, Text, StyleSheet, Modal, TextInput, type TextInputProps } from 'react-native';
import { AlertTriangle } from 'lucide-react-native';
import { AlertTriangle } from './icons';
import Button from './Button';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface DialogInput {
value: string;
@@ -55,7 +56,7 @@ export default function Dialog({
const confirmLabel = confirmText ?? t('common.confirm', 'Confirm');
const cancelLabel = cancelText ?? t('common.cancel', 'Cancel');
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<View style={styles.backdrop}>
<View style={styles.dialog}>
<View style={styles.content}>
@@ -71,7 +72,7 @@ export default function Dialog({
value={input.value}
onChangeText={input.onChangeText}
placeholder={input.placeholder}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
autoFocus
onSubmitEditing={confirmDisabled ? undefined : onConfirm}
accessibilityLabel={input.placeholder ?? title}
@@ -142,15 +143,17 @@ function makeStyles(c: ThemePalette) {
color: c.mutedForeground, // text-muted-foreground
marginTop: spacing.sm,
},
// The shared Input look: 40 dp, transparent, radius 6.
input: {
...typography.body,
color: c.text,
backgroundColor: c.surface,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: 10,
paddingVertical: 0,
minHeight: 40,
marginTop: spacing.md,
},
footer: {
+19 -10
View File
@@ -3,7 +3,7 @@ import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensio
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
import { Image as ImageIcon, ShieldCheck } from './icons';
import type { Email } from '../api/types';
import { extractCidRefs } from '../lib/email-html';
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
@@ -12,12 +12,14 @@ import { bodyDocument } from '../lib/email-body-document';
import { estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../lib/body-heights';
import { parseMailtoUrl } from '../lib/unsubscribe';
import { fetchInlineImageDataUri } from '../lib/email-export';
import { isSenderContentTrusted } from '../lib/trusted-senders';
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
import { useHasContacts } from '../lib/capabilities';
import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import { perfEndAfterPaint, perfLap } from '../lib/perf-marks';
import type { RootStackParamList } from '../navigation/types';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -527,6 +529,8 @@ export default function EmailBodyView({
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const hasContacts = useHasContacts();
const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
@@ -540,8 +544,8 @@ export default function EmailBodyView({
// entries (synced across devices) feed the external-content trust check. We
// do not create the book here — it is created lazily when a sender is trusted.
React.useEffect(() => {
if (trustedSendersAddressBook && !trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [trustedSendersAddressBook, trustedSendersLoaded, loadTrustedSendersBook]);
if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]);
// Most marketing email is authored against a white background, so dark-mode
// inversion can wreck logos/banners. With this flag the user opts to render
// emails on a light surface even while the rest of the app is dark; the
@@ -561,7 +565,7 @@ export default function EmailBodyView({
const text = picked.text;
const trusted = isSenderContentTrusted(senderEmail, {
isLocallyTrusted: isSenderTrusted,
syncEnabled: trustedSendersAddressBook,
syncEnabled: syncTrustedSenders,
trustedBookEmails: trustedSenderEmails,
});
@@ -680,8 +684,13 @@ export default function EmailBodyView({
[prepared.isHtml, prepared.applyInversion, quoteLabels],
);
// Performance harness: how much of opening a message is the WebView.
React.useEffect(() => { perfLap('open-message', 'body-mounted'); }, []);
const onMessage = (e: WebViewMessageEvent) => {
const data = e.nativeEvent.data;
// The body reported its size: it is laid out (performance harness).
perfEndAfterPaint('open-message');
// Swipe/zoom messages arrive as JSON; the height reporter posts a bare number.
if (data.charCodeAt(0) === 123 /* '{' */) {
try {
@@ -721,12 +730,11 @@ export default function EmailBodyView({
const onLoadImages = () => setAllowOnce(true);
const onTrustSender = () => {
if (senderEmail) {
// Keep the local allow-list for instant effect. The server-side "Trusted
// Senders" address book (synced across devices) is only written when
// the user opted into it - otherwise a book would silently appear in
// their contacts (matches the webmail).
// Keep the local allow-list for instant effect, and file the sender in
// the server-side "Trusted Senders" address book (synced across
// devices) unless the user turned that off.
addTrustedSender(senderEmail);
if (trustedSendersAddressBook) {
if (syncTrustedSenders) {
void addToTrustedSendersBook(senderEmail).catch(() => {});
}
setAllowOnce(true);
@@ -786,6 +794,7 @@ export default function EmailBodyView({
injectedJavaScript={injectedJs}
onMessage={onMessage}
onLoadEnd={() => {
perfLap('open-message', 'webview-loaded');
// 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).
+8 -4
View File
@@ -3,12 +3,14 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Check, Mail } from 'lucide-react-native';
import { X, Check, Mail } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Identity } from '../api/types';
import { useLocaleStore } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
interface IdentitySheetProps {
visible: boolean;
@@ -25,6 +27,8 @@ export function IdentitySheet({
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
const { t } = useLocaleStore();
@@ -44,14 +48,14 @@ export function IdentitySheet({
}, [visible, slideY, overlayOpacity]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -60,7 +64,7 @@ export function IdentitySheet({
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('email_composer.from', 'From')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}
<Pressable onPress={onClose} hitSlop={10} style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
+17 -14
View File
@@ -9,16 +9,17 @@ interface InputProps extends TextInputProps {
containerStyle?: ViewStyle;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
/** React 19 passes `ref` as a prop; it reaches the TextInput (focus chaining). */
ref?: React.Ref<RNTextInput>;
}
/**
* Matches webmail input.tsx:
* - h-10 w-full rounded-md border border-input bg-background
* - px-3 py-2 text-sm text-foreground
* - placeholder:text-muted-foreground
* - hover:border-muted-foreground
* - focus:ring-2 ring-ring border-ring
* - disabled:opacity-50
* Matches webmail input.tsx in the standard theme:
* - h-10 rounded-md border border-input, transparent background
* - px-3 text-sm text-foreground, placeholder in muted-foreground
* - focus: border-ring
* - disabled: opacity-50
* - label above in text-sm font-medium text-foreground
*/
export default function Input({
label,
@@ -27,6 +28,7 @@ export default function Input({
leftIcon,
rightIcon,
style,
ref,
...props
}: InputProps) {
const c = useColors();
@@ -44,9 +46,10 @@ export default function Input({
]}>
{leftIcon && <View style={styles.iconLeft}>{leftIcon}</View>}
<RNTextInput
ref={ref}
{...props}
style={[styles.input, style]}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
/>
@@ -61,22 +64,22 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
label: {
...typography.bodyMedium,
color: c.textSecondary,
marginBottom: spacing.sm,
color: c.text,
marginBottom: 6,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.inputHeight, // h-10 = 40px
minHeight: componentSizes.inputHeight, // h-10 = 40px; grows with a larger system font
borderWidth: 1,
borderColor: c.border, // border-input
borderRadius: radius.sm, // rounded-md = 6px
backgroundColor: c.background, // bg-background
backgroundColor: 'transparent',
paddingHorizontal: spacing.md, // px-3 = 12px
},
// Colour only, so focusing doesn't shift the text by a pixel.
inputFocused: {
borderColor: c.borderFocus, // border-ring (blue-500)
borderWidth: 2,
borderColor: c.borderFocus,
},
inputError: {
borderColor: c.error,
+10 -6
View File
@@ -5,8 +5,8 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Folder,
X, Check, type LucideIcon,
} from 'lucide-react-native';
X, Check, type AppIcon,
} from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
@@ -14,8 +14,10 @@ import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { localizeMailboxName } from '../lib/mailbox-label';
import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
function moveTargetIcon(role: string | null | undefined, name: string): AppIcon {
const lower = name.toLowerCase();
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
if (role === 'sent' || lower.includes('sent')) return Send;
@@ -43,6 +45,8 @@ export function MoveSheet({
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -76,14 +80,14 @@ export function MoveSheet({
}, [mailboxes]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -92,7 +96,7 @@ export function MoveSheet({
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('context_menu.move_to', 'Move to folder')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}
<Pressable onPress={onClose} hitSlop={10} style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CloudOff } from 'lucide-react-native';
import { CloudOff } from './icons';
import { useNetworkStore } from '../stores/network-store';
import { useOutboxStore } from '../stores/outbox-store';
import { useLocaleStore } from '../stores/locale-store';
+1 -1
View File
@@ -4,7 +4,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { CloudDownload, X } from 'lucide-react-native';
import { CloudDownload, X } from './icons';
import { useOfflineCacheStore } from '../stores/offline-cache-store';
import { useUpdatesStore } from '../stores/updates-store';
import { formatBytes } from '../lib/offline-sync';
+184
View File
@@ -0,0 +1,184 @@
import React from 'react';
import {
View,
Text,
TextInput,
Pressable,
StyleSheet,
Platform,
KeyboardAvoidingView,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import * as Clipboard from 'expo-clipboard';
import { ClipboardPaste, X } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
import { describeLoginError } from '../lib/login-errors';
import { SafeAreaModal } from './SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface PasteSignInLinkModalProps {
visible: boolean;
onClose: () => void;
// Fires with a link that parsed; the parent closes the modal and signs in
// with it exactly as with a scanned code.
onSubmit: (payload: QrLoginPayload) => void;
}
/**
* For when the code can't be scanned (a phone showing the webmail itself, a
* broken camera): the same `bulwarkmail://` link, pasted as text.
*/
export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignInLinkModalProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((st) => st.t);
const [text, setText] = React.useState('');
const [error, setError] = React.useState<string | null>(null);
const [focused, setFocused] = React.useState(false);
React.useEffect(() => {
if (!visible) return;
setText('');
setError(null);
}, [visible]);
const paste = React.useCallback(async () => {
let value = '';
try {
value = await Clipboard.getStringAsync();
} catch {
value = '';
}
if (!value.trim()) {
setError(t('login.mobile.paste_clipboard_empty', 'Nothing to paste. Copy the link in the webmail first.'));
return;
}
setText(value.trim());
setError(null);
}, [t]);
const submit = React.useCallback(() => {
const payload = parsePastedSignInLink(text);
if (!payload) {
// A pairing link for a plain-http webmail: say why it won't be used.
const insecure = insecurePairingLinkError(text);
if (insecure) {
const copy = describeLoginError(insecure, { t });
setError(copy.detail ?? copy.title);
return;
}
setError(t('login.mobile.paste_invalid', "That isn't a Bulwark sign-in link"));
return;
}
onSubmit(payload);
}, [onSubmit, t, text]);
const title = t('login.mobile.paste_title', 'Paste sign-in link');
return (
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
<View style={styles.header}>
<Pressable
onPress={onClose}
hitSlop={10}
style={styles.closeButton}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={24} color={c.text} />
</Pressable>
<Text style={styles.title} accessibilityRole="header">{title}</Text>
<View style={styles.closeButton} />
</View>
<ScrollView
style={styles.flex}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.hint}>
{t('login.mobile.paste_hint', 'In the webmail, open Settings → Security → Link Mobile App, copy the sign-in link and paste it here.')}
</Text>
<TextInput
value={text}
onChangeText={(value) => {
setText(value);
setError(null);
}}
placeholder="bulwarkmail://pair?server=…&code=…"
placeholderTextColor={c.textMuted}
accessibilityLabel={t('login.mobile.paste_label', 'Sign-in link')}
multiline
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
textAlignVertical="top"
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={[styles.input, focused && styles.inputFocused, error ? styles.inputError : null]}
/>
{error ? (
<Text style={styles.error} accessibilityLiveRegion="polite">{error}</Text>
) : null}
<Button
variant="outline"
size="md"
onPress={() => void paste()}
icon={<ClipboardPaste size={16} color={c.text} />}
>
{t('login.mobile.paste_button', 'Paste')}
</Button>
<Button variant="default" size="md" onPress={submit} disabled={!text.trim()}>
{t('login.sign_in', 'Sign in')}
</Button>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</SafeAreaModal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
flex: { flex: 1 },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm,
paddingBottom: spacing.md,
},
closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
title: { ...typography.h3, color: c.text },
content: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xxxl, gap: spacing.lg },
hint: { ...typography.body, color: c.textSecondary },
input: {
...typography.body,
color: c.text,
minHeight: 96,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.background,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm,
},
inputFocused: { borderColor: c.borderFocus, borderWidth: 2 },
inputError: { borderColor: c.error },
error: { ...typography.caption, color: c.error, marginTop: -spacing.sm },
});
}
+6 -7
View File
@@ -1,7 +1,8 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BellRing, X } from 'lucide-react-native';
import { useFloatingCardFrame } from '../lib/use-layout';
import { BellRing, X } from './icons';
import Button from './Button';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -24,8 +25,6 @@ import {
// Mirrors the webmail's push-notification-prompt: a short delay after login
// so the inbox renders first, and never again for an account once dismissed.
const PROMPT_DELAY_MS = 1500;
// Clears the tab bar, like the toasts (ToastHost).
const TAB_BAR_CLEARANCE = 64;
/**
* Once-per-account invitation to turn on background notifications. Shown
@@ -51,6 +50,8 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
const duration = useAnimDuration(200);
const appear = React.useRef(new Animated.Value(0)).current;
@@ -117,7 +118,7 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
style={[
styles.wrap,
{
bottom: insets.bottom + TAB_BAR_CLEARANCE,
left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom,
opacity: appear,
transform: [{ translateY: appear.interpolate({ inputRange: [0, 1], outputRange: [24, 0] }) }],
},
@@ -149,7 +150,7 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
</View>
<Pressable
onPress={handleDismiss}
hitSlop={8}
hitSlop={14}
accessibilityRole="button"
accessibilityLabel={t('settings.notifications.push.dismiss_aria', 'Dismiss notification prompt')}
style={styles.close}
@@ -165,8 +166,6 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
card: {
+6 -4
View File
@@ -1,12 +1,14 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native';
import { View, Text, Pressable, StyleSheet, Platform, Linking } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from 'lucide-react-native';
import { X } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
import { SafeAreaModal } from './SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface QrScanModalProps {
visible: boolean;
@@ -42,7 +44,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}>
{permission?.granted ? (
<CameraView
@@ -104,7 +106,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
) : null}
</SafeAreaView>
</View>
</Modal>
</SafeAreaModal>
);
}
+29 -2
View File
@@ -3,6 +3,7 @@ import { StyleSheet, View, Platform } from 'react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useColors } from '../theme/colors';
import type { ThemePalette } from '../theme/tokens';
import { perfEndAfterPaint } from '../lib/perf-marks';
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
export type RichTextCommand =
@@ -65,6 +66,10 @@ export interface RichTextEditorHandle {
*/
getHtml(timeoutMs?: number): Promise<string>;
focus(): void;
/** Focus with the caret where new text goes (before signature / quote). */
focusEnd(): void;
/** Drop the caret, which closes the keyboard. */
blur(): void;
}
interface Props {
@@ -76,10 +81,16 @@ interface Props {
onSelectionChange?: (state: RichTextSelectionState) => void;
onFocus?: () => void;
onBlur?: () => void;
/**
* Android: the page undid a caret-reveal pan of `dy` px (dp) inside the
* WebView. The host should scroll its ScrollView down by `dy` so the caret
* stays visible (see `pinVisualViewport` in editor-html).
*/
onViewportShift?: (dy: number) => void;
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur },
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift },
ref,
) {
const c = useColors();
@@ -90,6 +101,8 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
const onSelectionRef = React.useRef(onSelectionChange);
const onFocusRef = React.useRef(onFocus);
const onBlurRef = React.useRef(onBlur);
const onViewportShiftRef = React.useRef(onViewportShift);
onViewportShiftRef.current = onViewportShift;
onChangeRef.current = onChange;
onSelectionRef.current = onSelectionChange;
onFocusRef.current = onFocus;
@@ -98,7 +111,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
// Build the source HTML once. Theming changes won't auto-rebuild the page,
// which is fine: themes change rarely and the user can recompose if needed.
const html = React.useMemo(
() => buildEditorHtml({ initialHtml, placeholder, c, blockRemoteImages }),
() => buildEditorHtml({
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
}),
// c reference changes per theme switch but we don't want to lose user
// edits on a theme change. Bake in the initial colors only.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -173,9 +188,15 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
pendingSnapshots.current.set(id, { resolve, reject, timer });
call(`window.__rne && window.__rne.getHtml(${id})`);
}),
focusEnd: () => {
call(`window.__rne && window.__rne.focusEnd()`);
},
focus: () => {
call(`window.__rne && window.__rne.focus()`);
},
blur: () => {
call(`document.activeElement && document.activeElement.blur()`);
},
}), [call]);
const onMessage = (event: WebViewMessageEvent) => {
@@ -187,6 +208,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
}
if (!readyRef.current) {
readyRef.current = true;
perfEndAfterPaint('compose-open');
const queued = queuedRef.current;
queuedRef.current = [];
for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`);
@@ -219,6 +241,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
case 'blur':
onBlurRef.current?.();
break;
case 'viewportShift': {
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
break;
}
}
};
+20
View File
@@ -0,0 +1,20 @@
import React from 'react';
import { Modal, type ModalProps } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
/**
* A Modal with its own safe-area provider. A Modal is a separate native
* window, and a SafeAreaView inside a Modal whose content slides in (a
* slide-up Modal, or a drawer or sheet the app animates itself) measured no
* insets: the view starts off screen. On iPhones with a notch or Dynamic
* Island headers (and their close buttons) ended up under the status bar,
* where taps don't reach them, and bottom rows on the home indicator. Use
* this for any Modal with a SafeAreaView inside.
*/
export function SafeAreaModal({ children, ...props }: ModalProps) {
return (
<Modal {...props}>
<SafeAreaProvider>{children}</SafeAreaProvider>
</Modal>
);
}
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -4,8 +4,8 @@ import {
} from 'react-native';
import {
Archive, Trash2, ShieldAlert, ShieldCheck, MailOpen, Mail, Star, Pin, PinOff, FolderInput,
type LucideIcon,
} from 'lucide-react-native';
type AppIcon,
} from './icons';
import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore } from '../stores/locale-store';
@@ -43,7 +43,7 @@ export interface SwipeContext {
// Distance the row flies off-screen by before the action callback fires.
const EXIT_DISTANCE = 600;
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: AppIcon; bg: string; defaultLabel: string }> = {
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
delete: { icon: Trash2, bg: '#b91c1c', defaultLabel: 'Delete' },
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
@@ -68,7 +68,7 @@ function actionLabel(action: SwipeAction, context: SwipeContext, t: Translate):
}
}
function actionIcon(action: Exclude<SwipeAction, 'none'>, context: SwipeContext): LucideIcon {
function actionIcon(action: Exclude<SwipeAction, 'none'>, context: SwipeContext): AppIcon {
if (action === 'spam' && context.inJunk) return ShieldCheck;
if (action === 'read' && !context.unread) return Mail;
if (action === 'pin' && context.pinned) return PinOff;
+11 -7
View File
@@ -3,14 +3,16 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Check, Tag } from 'lucide-react-native';
import { spacing, radius, typography, colors as tokenColors, type ThemePalette } from '../theme/tokens';
import { X, Check, Tag } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useLocaleStore } from '../stores/locale-store';
import { getEmailTagIds } from '../lib/thread-utils';
import type { Email } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
interface TagSheetProps {
visible: boolean;
@@ -28,6 +30,8 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -65,14 +69,14 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
}, [keywords, selectedEmails]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -83,7 +87,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
<Text style={styles.title}>
{t('context_menu.items_selected', `${selectedEmails.length} emails selected`, { count: selectedEmails.length })}
</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}
<Pressable onPress={onClose} hitSlop={10} style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
@@ -98,7 +102,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
keywords.map((kw) => {
const token = keywordToken(kw.id);
const applied = allHaveToken(token);
const dot = tokenColors.tags[kw.color]?.dot ?? c.primary;
const dot = c.tags[kw.color]?.dot ?? c.primary;
return (
<Pressable
key={kw.id}
@@ -121,7 +125,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
onPress={() => onToggle(token, !applied)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Tag size={16} color={tokenColors.tags.gray.dot} fill={tokenColors.tags.gray.dot} />
<Tag size={16} color={c.tags.gray.dot} fill={c.tags.gray.dot} />
<Text style={styles.rowLabel} numberOfLines={1}>{id}</Text>
<Text style={styles.rowHint}>{t('email_list.unknown_tag', 'not defined here')}</Text>
{applied && <Check size={16} color={c.primary} />}
+8 -4
View File
@@ -3,13 +3,15 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Star, FileText } from 'lucide-react-native';
import { X, Star, FileText } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store';
import { filterTemplates } from '../lib/template-utils';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
interface TemplateSheetProps {
visible: boolean;
@@ -32,6 +34,8 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
const hydrate = useTemplatesStore((s) => s.hydrate);
const [query, setQuery] = React.useState('');
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -63,14 +67,14 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
}, [templates, query]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -79,7 +83,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('templates.picker_title', 'Choose a Template')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}
<Pressable onPress={onClose} hitSlop={10} style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
+234 -69
View File
@@ -1,12 +1,17 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import {
View, Text, StyleSheet, Pressable, Animated, Easing, PanResponder, useWindowDimensions,
type AccessibilityActionEvent, type LayoutChangeEvent,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useAnimDuration } from '../theme/dynamic';
import { useFloatingCardFrame } from '../lib/use-layout';
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from './icons';
import { spacing, radius, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import { useShouldAnimate } from '../theme/dynamic';
import { useToastStore, type Toast } from '../stores/toast-store';
import { useLocaleStore } from '../stores/locale-store';
import { shouldClaimToastSwipe, toastSwipeRelease } from './toast-swipe';
/**
* Renders the toast queue above the tab bar. App.tsx gives every stack screen
@@ -17,78 +22,209 @@ import { useLocaleStore } from '../stores/locale-store';
export function ToastHost(): React.ReactElement | null {
const toasts = useToastStore((s) => s.toasts);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
if (toasts.length === 0) return null;
return (
<View pointerEvents="box-none" style={[styles.wrap, { bottom: insets.bottom + 64 }]}>
<View
pointerEvents="box-none"
style={[styles.wrap, { left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom }]}
>
{toasts.map((t) => <ToastCard key={t.id} toast={t} />)}
</View>
);
}
const ENTER_MS = 200;
const EXIT_MS = 160;
// Icons and text in a status colour (repos/branding/APP.md): the palette's
// error and warning fills are too light for an icon on a light ground.
const STATUS_ICON = {
light: { error: '#dc2626', warning: '#d97706' },
dark: { error: '#f87171', warning: '#fbbf24' },
} as const;
/**
* The toast card from repos/branding/APP.md: a plain floating card whose icon
* and countdown line carry the status. No close button on the phone; a
* sideways swipe dismisses it.
*/
function ToastCard({ toast }: { toast: Toast }) {
const c = useColors();
const cardStyles = React.useMemo(() => makeStyles(c), [c]);
const scheme = useResolvedTheme();
const cardStyles = React.useMemo(() => makeStyles(c, scheme), [c, scheme]);
const t = useLocaleStore((s) => s.t);
const removeToast = useToastStore((s) => s.removeToast);
const duration = useAnimDuration(200);
const animate = useShouldAnimate();
const { width: screenWidth } = useWindowDimensions();
const opacity = React.useRef(new Animated.Value(0)).current;
const translateY = React.useRef(new Animated.Value(24)).current;
const translateX = React.useRef(new Animated.Value(0)).current;
// Share of the countdown line still filled, 1 → 0.
const remainingShare = React.useRef(new Animated.Value(1)).current;
const cardWidth = React.useRef(0);
React.useEffect(() => {
Animated.parallel([
const duration = animate ? ENTER_MS : 0;
const enter = Animated.parallel([
Animated.timing(opacity, { toValue: 1, duration, useNativeDriver: true }),
Animated.timing(translateY, { toValue: 0, duration, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
]).start();
]);
enter.start();
return () => enter.stop();
}, [animate, opacity, translateY]);
// Leave after the toast's duration, counted from when it was queued (a
// screen change remounts the card). The countdown line runs out with it;
// with animations off it stays full instead of moving.
React.useEffect(() => {
const remaining = Math.max(0, toast.duration - (Date.now() - toast.createdAt));
const timer = setTimeout(() => removeToast(toast.id), remaining);
return () => clearTimeout(timer);
}, [toast, opacity, translateY, duration, removeToast]);
if (!animate) {
remainingShare.setValue(1);
return () => clearTimeout(timer);
}
remainingShare.setValue(toast.duration > 0 ? remaining / toast.duration : 0);
const countdown = Animated.timing(remainingShare, {
toValue: 0,
duration: remaining,
easing: Easing.linear,
useNativeDriver: true,
});
countdown.start();
return () => {
clearTimeout(timer);
countdown.stop();
};
}, [toast, animate, remainingShare, removeToast]);
const accent = toast.type === 'success'
? c.success
: toast.type === 'error'
? c.error
: toast.type === 'warning'
? c.warning
: c.info;
// The PanResponder is built once; its handlers read these through a ref.
const latest = React.useRef({ animate, screenWidth, id: toast.id, removeToast });
latest.current = { animate, screenWidth, id: toast.id, removeToast };
const responder = React.useMemo(
() => {
const springBack = () => {
if (!latest.current.animate) {
translateX.setValue(0);
return;
}
Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
};
return PanResponder.create({
onStartShouldSetPanResponder: () => false,
onMoveShouldSetPanResponder: (_, g) => shouldClaimToastSwipe(g),
onPanResponderGrant: () => translateX.stopAnimation(),
onPanResponderMove: (_, g) => translateX.setValue(g.dx),
onPanResponderRelease: (_, g) => {
const direction = toastSwipeRelease(g, cardWidth.current);
if (direction === 0) {
springBack();
return;
}
const { animate: animated, screenWidth: width, id, removeToast: remove } = latest.current;
Animated.timing(translateX, {
toValue: direction * width,
duration: animated ? EXIT_MS : 0,
useNativeDriver: true,
}).start(() => remove(id));
},
onPanResponderTerminate: springBack,
onPanResponderTerminationRequest: () => false,
});
},
[translateX],
);
// Fades as it is dragged away.
const cardOpacity = React.useMemo(
() => Animated.multiply(
opacity,
translateX.interpolate({
inputRange: [-screenWidth, 0, screenWidth],
outputRange: [0, 1, 0],
extrapolate: 'clamp',
}),
),
[opacity, translateX, screenWidth],
);
const onLayout = (e: LayoutChangeEvent) => {
cardWidth.current = e.nativeEvent.layout.width;
};
const statusIcon = STATUS_ICON[scheme];
const iconColor = toast.type === 'error'
? statusIcon.error
: toast.type === 'warning'
? statusIcon.warning
: c.mutedForeground;
const countdownColor = toast.type === 'error'
? c.error
: toast.type === 'warning'
? c.warning
: c.mutedForeground;
const Icon = toast.type === 'success'
? CheckCircle2
: toast.type === 'error'
? XCircle
? AlertCircle
: toast.type === 'warning'
? AlertTriangle
: Info;
// Without a close button, screen readers dismiss through an action.
const dismissActions = React.useMemo(
() => [{ name: 'dismiss', label: t('common.dismiss', 'Dismiss') }, { name: 'escape' }],
[t],
);
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (e.nativeEvent.actionName === 'dismiss' || e.nativeEvent.actionName === 'escape') {
removeToast(toast.id);
}
};
return (
<Animated.View
style={[cardStyles.card, { borderLeftColor: accent, opacity, transform: [{ translateY }] }]}
accessibilityRole="alert"
accessibilityLiveRegion="polite"
style={[cardStyles.shadow, { opacity: cardOpacity, transform: [{ translateY }, { translateX }] }]}
onLayout={onLayout}
{...responder.panHandlers}
>
<Icon size={18} color={accent} />
<View style={{ flex: 1 }}>
<Text style={cardStyles.title} numberOfLines={2}>{toast.title}</Text>
{toast.message ? <Text style={cardStyles.message} numberOfLines={3}>{toast.message}</Text> : null}
</View>
{toast.action ? (
<Pressable
onPress={() => { toast.action?.onPress(); removeToast(toast.id); }}
hitSlop={8}
accessibilityRole="button"
style={({ pressed }) => [cardStyles.actionBtn, pressed && cardStyles.actionBtnPressed]}
<View style={cardStyles.card} accessibilityRole="alert" accessibilityLiveRegion="polite">
<Icon size={18} color={iconColor} style={cardStyles.icon} />
<View
style={cardStyles.body}
accessible
accessibilityActions={dismissActions}
onAccessibilityAction={onAccessibilityAction}
>
<Text style={[cardStyles.actionText, { color: accent }]}>{toast.action.label}</Text>
</Pressable>
) : null}
<Pressable
onPress={() => removeToast(toast.id)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.dismiss', 'Dismiss')}
style={cardStyles.close}
>
<X size={14} color={c.mutedForeground} />
</Pressable>
<Text style={cardStyles.title} numberOfLines={2}>{toast.title}</Text>
{toast.message ? <Text style={cardStyles.message} numberOfLines={3}>{toast.message}</Text> : null}
</View>
{toast.action ? (
<Pressable
onPress={() => { toast.action?.onPress(); removeToast(toast.id); }}
hitSlop={8}
accessibilityRole="button"
style={({ pressed }) => [cardStyles.action, pressed && cardStyles.actionPressed]}
>
{/* The underline is a border so it can sit 3px under the
baseline; textDecorationLine has no offset in RN. */}
<View style={cardStyles.actionUnderline}>
<Text style={cardStyles.actionText} numberOfLines={1}>{toast.action.label}</Text>
</View>
</Pressable>
) : null}
<View pointerEvents="none" style={cardStyles.countdownTrack} />
<Animated.View
pointerEvents="none"
style={[
cardStyles.countdownFill,
{ backgroundColor: countdownColor, transform: [{ scaleX: remainingShare }] },
]}
/>
</View>
</Animated.View>
);
}
@@ -96,38 +232,67 @@ function ToastCard({ toast }: { toast: Toast }) {
const styles = StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
gap: spacing.sm,
zIndex: 60,
},
});
function makeStyles(c: ThemePalette) {
const COUNTDOWN_HEIGHT = 2;
function makeStyles(c: ThemePalette, scheme: 'light' | 'dark') {
return StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm + 2,
paddingLeft: spacing.md,
paddingRight: spacing.sm,
borderRadius: radius.md,
borderLeftWidth: 3,
// Carries the shadow; the card inside clips the countdown line to the
// rounded corners, which would clip an iOS shadow on the same view.
shadow: {
borderRadius: radius.sm,
backgroundColor: c.popover,
borderWidth: 1,
borderColor: c.border,
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: scheme === 'dark' ? 0.4 : 0.1,
shadowRadius: 16,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
title: { ...typography.bodyMedium, color: c.text },
message: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
actionBtn: { paddingHorizontal: spacing.sm, paddingVertical: 6, borderRadius: radius.sm },
actionBtnPressed: { backgroundColor: c.muted },
actionText: { ...typography.captionMedium },
close: { padding: 4 },
card: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
paddingTop: 12,
paddingLeft: 14,
paddingRight: 14,
// Room for the countdown line.
paddingBottom: 14,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.popover,
overflow: 'hidden',
},
// Centred on the title's first line.
icon: { marginTop: 1 },
body: { flex: 1 },
title: { fontSize: 14.5, lineHeight: 20, fontWeight: '500', color: c.text },
message: { fontSize: 13, lineHeight: 18, color: c.mutedForeground, marginTop: 2 },
action: { alignSelf: 'center' },
actionPressed: { opacity: 0.6 },
actionUnderline: { borderBottomWidth: 1, borderBottomColor: c.text },
actionText: { fontSize: 13, lineHeight: 16, fontWeight: '600', color: c.text },
countdownTrack: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: COUNTDOWN_HEIGHT,
backgroundColor: c.text,
opacity: 0.06,
},
countdownFill: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: COUNTDOWN_HEIGHT,
opacity: 0.6,
transformOrigin: 'left',
},
});
}
+13 -7
View File
@@ -12,11 +12,9 @@ interface ToggleSwitchProps {
}
/**
* Matches webmail ToggleSwitch:
* - h-6 w-11 rounded-full
* - checked: bg-primary, unchecked: bg-muted
* - thumb: h-4 w-4 rounded-full bg-background
* - translate-x-6 (checked) / translate-x-1 (unchecked)
* The webmail switch in the standard theme: a 44 x 24 track, bg-primary when
* on (knob in primary-foreground), bg-muted when off (knob in background).
* The knob is 20 dp with 2 dp of track around it.
*/
export default function ToggleSwitch({ value, onValueChange, disabled = false, accessibilityLabel }: ToggleSwitchProps) {
const c = useColors();
@@ -29,6 +27,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false, a
disabled && styles.disabled,
]}
onPress={() => !disabled && onValueChange(!value)}
// The track is 44 x 24; the touch area reaches 48 dp high.
hitSlop={{ top: 12, bottom: 12, left: 4, right: 4 }}
accessibilityRole="switch"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ checked: value, disabled }}
@@ -36,7 +36,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false, a
<View
style={[
styles.thumb,
{ transform: [{ translateX: value ? 24 : 4 }] },
value ? styles.thumbOn : styles.thumbOff,
{ transform: [{ translateX: value ? 22 : 2 }] },
]}
/>
</Pressable>
@@ -58,9 +59,14 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
},
thumb: {
width: componentSizes.toggleThumb, // h-4 w-4 = 16
width: componentSizes.toggleThumb,
height: componentSizes.toggleThumb,
borderRadius: radius.full,
},
thumbOn: {
backgroundColor: c.primaryForeground,
},
thumbOff: {
backgroundColor: c.background,
},
disabled: {
+9 -8
View File
@@ -1,18 +1,18 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFloatingCardFrame } from '../lib/use-layout';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { undoSnackbarColors } from './undo-snackbar-colors';
import { useEmailStore } from '../stores/email-store';
import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../stores/send-undo-store';
import { useLocaleStore } from '../stores/locale-store';
import type { RootStackParamList } from '../navigation/types';
const VISIBLE_MS = 5000;
// Clears the tab bar and the viewer's action bar, like the toasts (ToastHost).
const BOTTOM_CLEARANCE = 64;
interface Shown {
label: string;
@@ -32,6 +32,8 @@ export function UndoSnackbar() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const entry = useEmailStore((s) => s.pendingUndo);
@@ -128,7 +130,7 @@ export function UndoSnackbar() {
pointerEvents="box-none"
style={[
styles.wrap,
{ bottom: insets.bottom + BOTTOM_CLEARANCE, opacity, transform: [{ translateY: slideY }] },
{ left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom, opacity, transform: [{ translateY: slideY }] },
]}
>
<View style={styles.bar}>
@@ -157,11 +159,10 @@ export function UndoSnackbar() {
}
function makeStyles(c: ThemePalette) {
const colors = undoSnackbarColors(c);
return StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
bar: {
@@ -171,7 +172,7 @@ function makeStyles(c: ThemePalette) {
paddingLeft: spacing.lg,
paddingRight: spacing.sm,
borderRadius: radius.md,
backgroundColor: c.text,
backgroundColor: colors.bar,
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 12,
@@ -180,7 +181,7 @@ function makeStyles(c: ThemePalette) {
},
label: {
...typography.body,
color: c.background,
color: colors.label,
flex: 1,
},
undoBtn: {
@@ -193,7 +194,7 @@ function makeStyles(c: ThemePalette) {
},
undoText: {
...typography.bodySemibold,
color: c.primary,
color: colors.action,
letterSpacing: 0.5,
},
sendNowText: {
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Download, ShieldAlert, X } from 'lucide-react-native';
import { Download, ShieldAlert, X } from './icons';
import { useUpdatesStore } from '../stores/updates-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -11,6 +11,7 @@ const SRC = join(__dirname, '..', '..');
const TRANSLATED_FILES: Record<string, string[]> = {
'screens/CalendarScreen.tsx': [],
'components/calendar/AgendaView.tsx': [],
'components/calendar/EventBlock.tsx': [],
'components/calendar/EventCard.tsx': [],
'components/calendar/MonthScrollView.tsx': [],
'components/calendar/MonthView.tsx': [],
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// A SafeAreaView inside a react-native Modal whose content starts off screen
// measured no insets on iOS: the Modal is its own window, and the view was
// measured before it slid in. On iPhones with a notch or Dynamic Island the
// header and its close button sat under the status bar, out of reach
// (attachment preview, QR scanner, paste sign-in link, filter and event
// editors), the drawers' account headers did too, and the bottom sheets'
// last rows sat on the home indicator. Such modals use SafeAreaModal, which
// gives the window its own SafeAreaProvider. Slid in means animationType
// "slide", or "none" with the app's own slide animation (drawers, sheets).
const SRC = path.resolve(__dirname, '../..');
function sourceFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
return e.name.endsWith('.tsx') ? [p] : [];
});
}
/** Plain <Modal> elements: the opening tag and what is between the tags. */
function modals(source: string): { tag: string; body: string }[] {
return modalTags(source).map((tag) => {
const start = source.indexOf(tag) + tag.length;
const end = source.indexOf('</Modal>', start);
return { tag, body: source.slice(start, end < 0 ? undefined : end) };
});
}
/** Opening tags of plain <Modal> elements, attributes included. */
function modalTags(source: string): string[] {
const tags: string[] = [];
let at = source.indexOf('<Modal');
while (at >= 0) {
if (/[\s>]/.test(source[at + 6] ?? '')) {
let depth = 0;
let i = at;
for (; i < source.length; i++) {
const ch = source[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
}
tags.push(source.slice(at, i + 1));
}
at = source.indexOf('<Modal', at + 6);
}
return tags;
}
describe('modals that slide in with a SafeAreaView', () => {
it('use SafeAreaModal so their content clears the status bar and home indicator', () => {
const offenders = sourceFiles(SRC)
.filter((file) => {
const source = fs.readFileSync(file, 'utf8');
if (!source.includes('SafeAreaView')) return false;
return modals(source).some(({ tag, body }) =>
/animationType="slide"/.test(tag)
|| (/animationType="none"/.test(tag) && body.includes('<SafeAreaView')));
})
.map((file) => path.relative(SRC, file));
expect(offenders).toEqual([]);
});
// The mail drawer builds its panel (with the SafeAreaView) outside the
// modal's tags, so the check above can't see it there.
it('include the side drawers', () => {
const drawers = sourceFiles(SRC).filter((file) => /SidebarDrawer\.tsx$/.test(file));
expect(drawers.length).toBeGreaterThanOrEqual(3);
for (const file of drawers) {
const source = fs.readFileSync(file, 'utf8');
expect(modalTags(source).filter((tag) => /animationType="none"/.test(tag)), path.relative(SRC, file)).toEqual([]);
expect(source).toContain('<SafeAreaModal');
}
});
it('finds the tags it is looking for', () => {
const tags = modalTags('<View><Modal visible={x} onRequestClose={() => close()} animationType="slide">\n</Modal></View>');
expect(tags).toHaveLength(1);
expect(tags[0]).toContain('animationType="slide"');
});
});
@@ -41,6 +41,8 @@ const TRANSLATED_FILES: Record<string, string[]> = {
'components/TemplateSheet.tsx': [],
'components/IdentitySheet.tsx': [],
'components/QrScanModal.tsx': [],
// The input's placeholder is the link's own format.
'components/PasteSignInLinkModal.tsx': ['bulwarkmail://pair?server=…&code=…'],
'components/email/ActionSheet.tsx': [],
'components/email/AddressActionSheet.tsx': [],
'components/email/CalendarInvitationBanner.tsx': [],
+10 -3
View File
@@ -8,7 +8,7 @@ import { findUntranslatedLiterals } from './untranslated-literals';
// Pane file (relative to src/components/) → texts that stay as they are on
// purpose: product names, units, format patterns and sample data.
const TRANSLATED_PANES: Record<string, string[]> = {
'settings/AboutDataSettings.tsx': ['MB', 'GitHub'],
'settings/AboutSettings.tsx': [],
'settings/AccountSecuritySettings.tsx': ['AES-128', 'AES-256'],
'settings/AccountSettings.tsx': [],
'settings/AppearanceSettings.tsx': [],
@@ -31,15 +31,22 @@ const TRANSLATED_PANES: Record<string, string[]> = {
'settings/LanguageSettings.tsx': [],
'settings/LayoutSettings.tsx': [],
'settings/NotificationSettings.tsx': [],
'settings/PluginsSettings.tsx': [],
'settings/LicencesSettings.tsx': ['GNU Affero General Public License v3.0'],
'settings/PrivacySettings.tsx': [],
'settings/ReadingSettings.tsx': [],
'settings/SidebarAppsSettings.tsx': ['https://example.com'],
'settings/SmimeSettings.tsx': [],
'settings/StorageSettings.tsx': ['MB'],
'settings/TemplateSettings.tsx': ['JSON', '{"templates": [...]}'],
'settings/ThemesSettings.tsx': [],
'settings/TroubleshootingSettings.tsx': [],
'settings/UpdatesSettings.tsx': [],
'settings/VacationSettings.tsx': ['YYYY-MM-DD HH:MM'],
'settings/settings-section.tsx': [],
// Device sync (Android, #34).
'settings/device-sync/DeviceSyncSection.tsx': [],
'settings/device-sync/DeviceSyncAccountRow.tsx': [],
'settings/device-sync/DeviceSyncCollectionsSheet.tsx': [],
'settings/device-sync/status.ts': [],
'filters/FilterRuleModal.tsx': [],
'filters/SieveEditorSheet.tsx': [],
};
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Sheets and menus were built as a tappable backdrop (Pressable or
// TouchableWithoutFeedback) wrapping a card that swallows taps. Both are
// accessible elements by default, and an accessible element hides its
// children from VoiceOver and TalkBack: the compose "Attach" and "Schedule
// send" sheets read as one element ("Attach, Photos & Videos, Camera, Files,
// Cancel") whose options couldn't be picked one by one. The wrappers that
// only catch taps are marked accessible={false}, so the options inside stay
// reachable.
const SRC = path.resolve(__dirname, '../..');
function sourceFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
return e.name.endsWith('.tsx') ? [p] : [];
});
}
function openingTags(source: string, name: string): { tag: string; line: number }[] {
const out: { tag: string; line: number }[] = [];
const re = new RegExp(`<${name}\\b`, 'g');
let m: RegExpExecArray | null;
while ((m = re.exec(source))) {
let depth = 0;
let i = m.index;
for (; i < source.length; i++) {
const ch = source[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
}
out.push({ tag: source.slice(m.index, i + 1), line: source.slice(0, m.index).split('\n').length });
}
return out;
}
/** A wrapper that only catches taps and still hides what it wraps. */
export function isHidingWrapper(tag: string): boolean {
if (/accessible=\{false\}/.test(tag)) return false;
if (/\/\s*>$/.test(tag)) return false; // self-closing: wraps nothing
if (tag.startsWith('<TouchableWithoutFeedback')) return true;
if (/onPress=\{\(\) => \{\}\}/.test(tag)) return true;
return /style=\{styles\.\w*(Backdrop|backdrop|Overlay|overlay)\}/.test(tag);
}
describe('sheet backdrops', () => {
it('do not hide the options they wrap from screen readers', () => {
const offenders: string[] = [];
for (const file of sourceFiles(SRC)) {
const source = fs.readFileSync(file, 'utf8');
for (const name of ['Pressable', 'TouchableWithoutFeedback']) {
for (const { tag, line } of openingTags(source, name)) {
if (isHidingWrapper(tag)) offenders.push(`${path.relative(SRC, file)}:${line}`);
}
}
}
expect(offenders).toEqual([]);
});
it('recognises the wrappers it looks for', () => {
expect(isHidingWrapper('<Pressable style={styles.modalBackdrop} onPress={onClose}>')).toBe(true);
expect(isHidingWrapper('<Pressable style={styles.card} onPress={() => {}}>')).toBe(true);
expect(isHidingWrapper('<TouchableWithoutFeedback onPress={onClose}>')).toBe(true);
expect(isHidingWrapper('<Pressable accessible={false} style={styles.modalBackdrop} onPress={onClose}>')).toBe(false);
expect(isHidingWrapper('<Pressable style={styles.pickerOverlay} onPress={close} />')).toBe(false);
expect(isHidingWrapper('<Pressable style={styles.row} onPress={open}>')).toBe(false);
});
});
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { shouldClaimToastSwipe, toastSwipeRelease } from '../toast-swipe';
const g = (dx: number, dy = 0, vx = 0) => ({ dx, dy, vx });
describe('shouldClaimToastSwipe', () => {
it('takes sideways drags in either direction', () => {
expect(shouldClaimToastSwipe(g(10))).toBe(true);
expect(shouldClaimToastSwipe(g(-10, 4))).toBe(true);
});
it('leaves taps, jitter and vertical moves alone', () => {
expect(shouldClaimToastSwipe(g(3))).toBe(false);
expect(shouldClaimToastSwipe(g(10, 10))).toBe(false);
expect(shouldClaimToastSwipe(g(0, 30))).toBe(false);
});
});
describe('toastSwipeRelease', () => {
it('dismisses past 35% of the card, towards the drag', () => {
expect(toastSwipeRelease(g(120), 300)).toBe(1);
expect(toastSwipeRelease(g(-110), 300)).toBe(-1);
expect(toastSwipeRelease(g(100), 300)).toBe(0);
});
it('caps the distance on wide cards', () => {
expect(toastSwipeRelease(g(125), 800)).toBe(1);
expect(toastSwipeRelease(g(115), 800)).toBe(0);
});
it('dismisses a short flick', () => {
expect(toastSwipeRelease(g(40, 0, 0.8), 300)).toBe(1);
expect(toastSwipeRelease(g(-40, 0, -0.8), 300)).toBe(-1);
// A flick against the drag does not count.
expect(toastSwipeRelease(g(40, 0, -0.8), 300)).toBe(0);
expect(toastSwipeRelease(g(20, 0, 2), 300)).toBe(0);
});
it('does not dismiss a small drag before the card has a width', () => {
expect(toastSwipeRelease(g(20), 0)).toBe(0);
expect(toastSwipeRelease(g(130), 0)).toBe(1);
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { DARK_COLORS, LIGHT_COLORS, type ThemePalette } from '../../theme/tokens';
import { BUILTIN_THEMES } from '../../theme/builtin-themes';
import { undoSnackbarColors } from '../undo-snackbar-colors';
// The undo bar (UndoSnackbar) is drawn in the text colour. Its UNDO button
// used the primary colour, which is white in the default dark theme: white
// on white, so in dark mode the bar showed "Email archived" and no visible
// way to undo. The bound is 3:1, not 4.5:1, because Solarized's own text
// and background are a little under 4.5:1.
function luminance(hex: string): number {
const v = hex.replace('#', '');
const full = v.length === 3 ? v.split('').map((ch) => ch + ch).join('') : v.slice(0, 6);
const [r, g, b] = [0, 2, 4].map((i) => {
const c = parseInt(full.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a: string, b: string): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
const palettes: Array<[string, ThemePalette]> = [
['default light', LIGHT_COLORS],
['default dark', DARK_COLORS],
...BUILTIN_THEMES.flatMap((theme): Array<[string, ThemePalette]> => [
[`${theme.name} light`, { ...LIGHT_COLORS, ...theme.light } as ThemePalette],
[`${theme.name} dark`, { ...DARK_COLORS, ...theme.dark } as ThemePalette],
]),
];
describe('undo bar', () => {
it.each(palettes)('UNDO is readable on the bar (%s)', (_name, palette) => {
const { bar, action } = undoSnackbarColors(palette);
expect(contrast(action, bar)).toBeGreaterThanOrEqual(3);
});
});
+136 -51
View File
@@ -9,8 +9,8 @@ import {
type LayoutChangeEvent,
type ViewToken,
} from 'react-native';
import { format, type Locale } from 'date-fns';
import { displayNow, isDisplayToday, isDisplayTomorrow } from '../../lib/calendar-timezone';
import { endOfWeek, format, startOfWeek, type Locale } from 'date-fns';
import { displayNow, isDisplayToday } from '../../lib/calendar-timezone';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -47,23 +47,28 @@ interface AgendaViewProps {
/** Reports the first day in view as the user scrolls. */
onVisibleDateChange?: (date: Date) => void;
timeFormat?: TimeFormat;
weekStartsOn?: 0 | 1 | 6;
/** The user's addresses, to draw events they declined as inactive. */
currentUserEmails?: string[];
onSelectEvent?: (event: CalendarEvent) => void;
/** Tapping an empty "today" row creates an event on that day. */
onCreateAt?: (date: Date) => void;
}
interface DaySection extends AgendaDay {
key: string;
title: string;
/** Set on the first day of a month in the list: the month's name. */
monthTitle: string | null;
/** Set on the first day of a week in the list: the week's range. */
weekTitle: string | null;
}
function formatDayHeader(
date: Date,
t: (key: string, fallback?: string) => string,
locale: Locale,
): string {
// Today and tomorrow on a clock in the calendar's time zone.
if (isDisplayToday(date)) return t('calendar.events.today_header', 'Today');
if (isDisplayTomorrow(date)) return t('calendar.events.tomorrow_header', 'Tomorrow');
return format(date, 'EEEE, MMM d', { locale });
// The week separators: "Oct 11 – 17", or "Sep 27 – Oct 3" across months.
function formatWeekRange(date: Date, weekStartsOn: 0 | 1 | 6, locale: Locale): string {
const start = startOfWeek(date, { weekStartsOn });
const end = endOfWeek(date, { weekStartsOn });
const sameMonth = start.getMonth() === end.getMonth();
return `${format(start, 'MMM d', { locale })} – ${format(end, sameMonth ? 'd' : 'MMM d', { locale })}`;
}
const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 1 };
@@ -90,7 +95,10 @@ export function AgendaView({
isLoading = false,
onVisibleDateChange,
timeFormat,
weekStartsOn = 1,
currentUserEmails,
onSelectEvent,
onCreateAt,
}: AgendaViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -101,15 +109,21 @@ export function AgendaView({
[eventsByDay, events],
);
const sections = React.useMemo<DaySection[]>(
() =>
buildAgendaDays(index, loaded, displayNow()).map((day) => ({
...day,
key: dayKey(day.date),
title: formatDayHeader(day.date, t, locale),
})),
[index, loaded, t, locale],
);
// A heading where a month starts and a separator where a week starts,
// then the days.
const sections = React.useMemo<DaySection[]>(() => {
let lastMonth = '';
let lastWeek = '';
return buildAgendaDays(index, loaded, displayNow()).map((day) => {
const month = format(day.date, 'yyyy-MM');
const week = dayKey(startOfWeek(day.date, { weekStartsOn }));
const monthTitle = month !== lastMonth ? format(day.date, 'LLLL yyyy', { locale }) : null;
const weekTitle = week !== lastWeek ? formatWeekRange(day.date, weekStartsOn, locale) : null;
lastMonth = month;
lastWeek = week;
return { ...day, key: dayKey(day.date), monthTitle, weekTitle };
});
}, [index, loaded, weekStartsOn, locale]);
const listRef = React.useRef<SectionList<CalendarEvent, DaySection>>(null);
const sectionsRef = React.useRef(sections);
@@ -294,7 +308,7 @@ export function AgendaView({
ref={listRef}
sections={sections}
keyExtractor={(item, i) => `${item.id}:${i}`}
stickySectionHeadersEnabled
stickySectionHeadersEnabled={false}
// Earlier days are inserted above what the user is reading without
// moving it.
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
@@ -311,26 +325,42 @@ export function AgendaView({
initialNumToRender={12}
maxToRenderPerBatch={12}
windowSize={11}
renderSectionHeader={({ section }) => {
const today = isDisplayToday(section.date);
return (
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, today && styles.sectionTitleToday]}>
{section.title}
</Text>
<Text style={styles.sectionSub}>{format(section.date, 'PP', { locale })}</Text>
renderSectionHeader={({ section }) =>
section.monthTitle || section.weekTitle ? (
<View>
{section.monthTitle && <Text style={styles.monthTitle}>{section.monthTitle}</Text>}
{section.weekTitle && <Text style={styles.weekTitle}>{section.weekTitle}</Text>}
</View>
) : null
}
renderItem={({ item, index: i, section }) => (
<View style={[styles.row, i === section.data.length - 1 && styles.rowLast]}>
<DateColumn date={section.date} show={i === 0} locale={locale} styles={styles} />
<View style={styles.eventCol}>
<EventCard
event={item}
calendars={calendars}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
onPress={onSelectEvent}
day={section.date}
/>
</View>
);
}}
renderItem={({ item }) => (
<View style={styles.itemWrap}>
<EventCard event={item} calendars={calendars} timeFormat={timeFormat} onPress={onSelectEvent} />
</View>
)}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View style={styles.emptyDayWrap}>
<Text style={styles.emptyDayText}>{t('calendar.events.no_events', 'No events')}</Text>
<View style={[styles.row, styles.rowLast]}>
<DateColumn date={section.date} show locale={locale} styles={styles} />
<Pressable
style={styles.emptyDay}
onPress={onCreateAt ? () => onCreateAt(section.date) : undefined}
accessibilityRole={onCreateAt ? 'button' : undefined}
>
<Text style={styles.emptyDayText}>
{t('calendar.events.nothing_planned', 'Nothing planned. Tap to create.')}
</Text>
</Pressable>
</View>
) : null
}
@@ -338,24 +368,79 @@ export function AgendaView({
);
}
type AgendaStyles = ReturnType<typeof makeStyles>;
// The day on the left of its first row: weekday over the day number, today
// in a filled circle.
function DateColumn({
date,
show,
locale,
styles,
}: {
date: Date;
show: boolean;
locale: Locale;
styles: AgendaStyles;
}) {
if (!show) return <View style={styles.dateCol} />;
const today = isDisplayToday(date);
return (
<View style={styles.dateCol}>
<Text style={[styles.dateWeekday, today && styles.dateWeekdayToday]}>
{format(date, 'EEE', { locale })}
</Text>
<View style={[styles.dateNumber, today && styles.dateNumberToday]}>
<Text style={[styles.dateNumberText, today && styles.dateNumberTextToday]}>
{format(date, 'd')}
</Text>
</View>
</View>
);
}
const DATE_COL_WIDTH = 52;
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
sectionHeader: {
flexDirection: 'row',
alignItems: 'baseline',
gap: spacing.sm,
monthTitle: {
...typography.h2,
fontWeight: '500',
color: c.text,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
borderBottomWidth: 1,
borderBottomColor: c.border,
paddingTop: spacing.xxl,
paddingBottom: spacing.sm,
},
sectionTitle: { ...typography.bodyMedium, color: c.text },
sectionTitleToday: { color: c.primary },
sectionSub: { ...typography.caption, color: c.textMuted },
itemWrap: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
emptyDayWrap: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
emptyDayText: { ...typography.caption, color: c.textMuted, textAlign: 'center' },
weekTitle: {
...typography.caption,
color: c.textMuted,
paddingLeft: DATE_COL_WIDTH + spacing.sm,
paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingRight: spacing.lg,
paddingLeft: spacing.sm,
},
rowLast: { paddingBottom: spacing.sm },
dateCol: { width: DATE_COL_WIDTH, alignItems: 'center' },
dateWeekday: { ...typography.captionMedium, color: c.textSecondary },
dateWeekdayToday: { color: c.primary },
dateNumber: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
},
dateNumberToday: { backgroundColor: c.primary },
dateNumberText: { fontSize: 20, lineHeight: 26, fontWeight: '500', color: c.text },
dateNumberTextToday: { color: c.primaryForeground },
eventCol: { flex: 1, marginLeft: spacing.md },
emptyDay: { flex: 1, marginLeft: spacing.md, paddingTop: 18 },
emptyDayText: { ...typography.body, color: c.textSecondary },
emptyWrap: { paddingVertical: spacing.xl, alignItems: 'center' },
edge: {
paddingHorizontal: spacing.lg,
@@ -10,13 +10,15 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Check } from 'lucide-react-native';
import { Check } from '../icons';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
import Button from '../Button';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
export interface CalendarEditValues {
name: string;
@@ -35,6 +37,8 @@ interface CalendarEditSheetProps {
// Create / rename / recolour / describe a calendar. The same fields the
// webmail's calendar-management settings offer, as a bottom sheet.
export function CalendarEditSheet({ visible, calendar, onSave, onClose }: CalendarEditSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -69,10 +73,10 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
};
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<Text style={styles.title}>
{calendar
+157
View File
@@ -0,0 +1,157 @@
import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Animated, Easing, Pressable, StyleSheet, Text, View } from 'react-native';
import { CalendarPlus, CircleCheck, Plus } from '../icons';
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAnimDuration } from '../../theme/dynamic';
import { useLocaleStore } from '../../stores/locale-store';
interface CalendarFabProps {
onNewEvent: () => void;
/** With tasks turned on, the button first offers "Event" and "Task". */
onNewTask?: () => void;
}
/**
* The floating create button in the corner of the calendar, styled like the
* mail list's compose button. Without tasks it creates an event at once;
* with tasks it opens a small menu over a scrim.
*/
export function CalendarFab({ onNewEvent, onNewTask }: CalendarFabProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [open, setOpen] = React.useState(false);
const progress = React.useRef(new Animated.Value(0)).current;
const duration = useAnimDuration(160);
React.useEffect(() => {
Animated.timing(progress, {
toValue: open ? 1 : 0,
duration,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
}, [open, progress, duration]);
const pick = (action: () => void) => {
setOpen(false);
action();
};
const insets = useSafeAreaInsets();
const onMainPress = () => {
if (!onNewTask) {
onNewEvent();
return;
}
if (open) pick(onNewEvent);
else setOpen(true);
};
const rotate = progress.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '90deg'] });
const lift = progress.interpolate({ inputRange: [0, 1], outputRange: [12, 0] });
return (
<>
{open && (
<Animated.View style={[styles.scrim, { opacity: progress }]}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => setOpen(false)}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
/>
</Animated.View>
)}
{/* Clear of a side inset (camera cutout, navigation bar) in landscape. */}
<View style={[styles.anchor, { right: spacing.lg + insets.right }]} pointerEvents="box-none">
{open && onNewTask && (
<Animated.View style={[styles.option, { opacity: progress, transform: [{ translateY: lift }] }]}>
<Text style={styles.optionLabel}>{t('calendar.fab.task', 'Task')}</Text>
<Pressable
style={styles.miniFab}
onPress={() => pick(onNewTask)}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_task', 'New task')}
>
<CircleCheck size={20} color={c.text} />
</Pressable>
</Animated.View>
)}
<View style={styles.mainRow}>
{open && (
<Animated.Text style={[styles.optionLabel, { opacity: progress }]}>
{t('calendar.fab.event', 'Event')}
</Animated.Text>
)}
<Pressable
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
onPress={onMainPress}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_event', 'New event')}
>
{open ? (
<CalendarPlus size={24} color={c.background} />
) : (
<Animated.View style={{ transform: [{ rotate }] }}>
<Plus size={26} color={c.background} />
</Animated.View>
)}
</Pressable>
</View>
</View>
</>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
scrim: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.55)',
zIndex: 20,
},
anchor: {
position: 'absolute',
right: spacing.lg,
bottom: spacing.lg,
alignItems: 'flex-end',
gap: spacing.lg,
zIndex: 21,
},
option: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, marginRight: 8 },
mainRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
optionLabel: { ...typography.baseMedium, color: '#ffffff' },
miniFab: {
width: 40,
height: 40,
borderRadius: radius.full,
backgroundColor: c.surface,
alignItems: 'center',
justifyContent: 'center',
elevation: 3,
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
},
// Same as the compose button on the mail list (EmailListScreen).
fab: {
width: componentSizes.fab,
height: componentSizes.fab,
borderRadius: radius.full,
backgroundColor: c.text,
alignItems: 'center',
justifyContent: 'center',
elevation: 6,
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 6,
shadowOffset: { width: 0, height: 3 },
},
fabPressed: { opacity: 0.85 },
});
}
+12 -5
View File
@@ -11,13 +11,15 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Trash2, UserPlus, Users } from 'lucide-react-native';
import { Trash2, UserPlus, Users } from '../icons';
import { listPrincipals, ownPrincipalId, type PrincipalListing } from '../../api/files';
import type { Calendar, CalendarRights, Principal } from '../../api/types';
import { directoryNotice } from '../../lib/principal-directory';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager';
@@ -70,6 +72,8 @@ interface CalendarShareSheetProps {
// principal list comes from the same Principal/query the Files share sheet
// uses.
export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShareSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -110,6 +114,9 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
);
const candidates = React.useMemo(() => {
// The sheet is mounted with the Calendar tab, before a session may exist
// (an offline cold start): nobody to list yet, and no own id to read.
if (principals.length === 0) return [];
const self = ownPrincipalId();
const q = search.trim().toLowerCase();
return principals
@@ -177,10 +184,10 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
};
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={styles.titleRow}>
<Users size={18} color={c.textMuted} />
@@ -3,7 +3,6 @@ import {
Animated,
Dimensions,
Easing,
Modal,
Pressable,
ScrollView,
StyleSheet,
@@ -12,8 +11,9 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2,
} from 'lucide-react-native';
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw, ArrowUp, ArrowDown,
type AppIcon,
} from '../icons';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -22,9 +22,22 @@ import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { SafeAreaModal } from '../SafeAreaModal';
/** A calendar view listed at the top of the drawer. */
export interface DrawerViewOption {
key: string;
label: string;
Icon: AppIcon;
}
interface CalendarSidebarDrawerProps {
visible: boolean;
views?: DrawerViewOption[];
activeView?: string;
onSelectView?: (key: string) => void;
onRefresh?: () => void;
calendars: Calendar[];
hiddenCalendarIds: string[];
onToggle: (id: string) => void;
@@ -42,12 +55,19 @@ interface CalendarSidebarDrawerProps {
onShare?: (calendar: Calendar) => void;
onClear?: (calendar: Calendar) => void;
onDelete?: (calendar: Calendar) => void;
// Move one of the user's own calendars next to `targetId`, a neighbour in
// the same section (before it when moving up, after it when moving down).
onMove?: (calendar: Calendar, targetId: string) => void;
// Client-side iCal subscriptions are managed from the subscriptions sheet.
isSubscriptionCalendar?: (calendarId: string) => boolean;
}
export function CalendarSidebarDrawer({
visible,
views,
activeView,
onSelectView,
onRefresh,
calendars,
hiddenCalendarIds,
onToggle,
@@ -62,6 +82,7 @@ export function CalendarSidebarDrawer({
onShare,
onClear,
onDelete,
onMove,
isSubscriptionCalendar,
}: CalendarSidebarDrawerProps) {
const c = useColors();
@@ -131,11 +152,12 @@ export function CalendarSidebarDrawer({
onShare,
onClear,
onDelete,
onMove,
isSubscriptionCalendar,
};
return (
<Modal
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
@@ -161,10 +183,44 @@ export function CalendarSidebarDrawer({
>
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>{t('calendar.my_calendars', 'Calendars')}</Text>
<Text style={styles.headerTitle}>{t('calendar.title', 'Calendar')}</Text>
</View>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{views && views.length > 0 && (
<View style={styles.viewsSection}>
{views.map(({ key, label, Icon }) => {
const active = key === activeView;
return (
<Pressable
key={key}
onPress={() => onSelectView?.(key)}
style={({ pressed }) => [
styles.viewRow,
active && styles.viewRowActive,
pressed && !active && styles.rowPressed,
]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Icon size={16} color={active ? c.text : c.textSecondary} />
<Text style={[styles.viewText, active && styles.viewTextActive]}>{label}</Text>
</Pressable>
);
})}
{onRefresh && (
<Pressable
onPress={onRefresh}
style={({ pressed }) => [styles.viewRow, styles.refreshRow, pressed && styles.rowPressed]}
accessibilityRole="button"
>
<RefreshCw size={16} color={c.textSecondary} />
<Text style={styles.viewText}>{t('common.refresh', 'Refresh')}</Text>
</Pressable>
)}
</View>
)}
{calendars.length === 0 && (
<Text style={styles.empty}>{t('calendar.drawer.no_calendars', 'No calendars yet.')}</Text>
)}
@@ -215,7 +271,7 @@ export function CalendarSidebarDrawer({
</ScrollView>
</SafeAreaView>
</Animated.View>
</Modal>
</SafeAreaModal>
);
}
@@ -233,6 +289,7 @@ function Section({
onShare,
onClear,
onDelete,
onMove,
isSubscriptionCalendar,
}: {
title: string;
@@ -248,11 +305,14 @@ function Section({
onShare?: (calendar: Calendar) => void;
onClear?: (calendar: Calendar) => void;
onDelete?: (calendar: Calendar) => void;
onMove?: (calendar: Calendar, targetId: string) => void;
isSubscriptionCalendar?: (calendarId: string) => boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
// Neighbours a calendar can move past: the server calendars of this section.
const orderable = calendars.filter((cal) => cal.id !== BIRTHDAY_CALENDAR_ID);
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
@@ -269,7 +329,11 @@ function Section({
const canClear = !!onClear && isOwn && isWritableCalendar(cal);
// Like webmail: the default calendar and shared calendars can't be deleted.
const canDelete = !!onDelete && isOwn && !cal.isDefault && (!cal.myRights || cal.myRights.mayDelete !== false);
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete;
const orderIndex = orderable.indexOf(cal);
const previousId = isOwn && orderIndex > 0 ? orderable[orderIndex - 1].id : undefined;
const nextId = isOwn && orderIndex >= 0 ? orderable[orderIndex + 1]?.id : undefined;
const canMove = !!onMove && (!!previousId || !!nextId);
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete || canMove;
const expanded = expandedId === cal.id && hasActions;
return (
<View key={cal.id}>
@@ -304,6 +368,27 @@ function Section({
{expanded && (
<View style={styles.actionsPanel}>
{/* The panel stays open so a calendar can be moved several places. */}
{canMove && previousId && (
<Pressable
onPress={() => onMove!(cal, previousId)}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
accessibilityRole="button"
>
<ArrowUp size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_up', 'Move up')}</Text>
</Pressable>
)}
{canMove && nextId && (
<Pressable
onPress={() => onMove!(cal, nextId)}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
accessibilityRole="button"
>
<ArrowDown size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_down', 'Move down')}</Text>
</Pressable>
)}
{canSetDefault && (
<Pressable
onPress={() => { onExpand(null); onSetDefault!(cal); }}
@@ -431,6 +516,27 @@ function makeStyles(c: ThemePalette) {
headerTitle: { ...typography.h3, color: c.text },
scroll: { flex: 1 },
viewsSection: {
paddingTop: spacing.sm,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
// Same rows as the mail drawer's folders (SidebarDrawer).
viewRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
minHeight: 44,
borderLeftWidth: 2,
borderLeftColor: 'transparent',
},
viewRowActive: { backgroundColor: c.accent, borderLeftColor: c.primary },
refreshRow: { marginTop: spacing.xs },
viewText: { ...typography.body, color: c.text },
viewTextActive: { ...typography.bodySemibold },
scrollContent: { paddingBottom: spacing.lg },
empty: {
+169
View File
@@ -0,0 +1,169 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import type { EventBlockColors } from '../../lib/event-colors';
// Events in the week and day grids, shared by the paged WeekView and the
// scrolling TimeGridScrollView. Every event is a solid block of its calendar
// colour with a label colour computed from it; declined and cancelled ones
// are outlined on the page ground with a struck-through title
// (repos/branding/APP.md, "Calendar events"). Callers get the colours from
// eventBlockColors().
/** Corner radius of event blocks and bars. */
export const EVENT_RADIUS = 2;
/** Timed blocks this long or longer show their start time under the title. */
export const TIME_LINE_MIN_MINUTES = 45;
// Corners on a side the event continues past (the day before or after, the
// week before or after) stay square.
function corners(
axis: 'vertical' | 'horizontal',
continuesBefore: boolean,
continuesAfter: boolean,
r: number,
): ViewStyle {
const before = continuesBefore ? 0 : r;
const after = continuesAfter ? 0 : r;
return axis === 'vertical'
? {
borderTopLeftRadius: before,
borderTopRightRadius: before,
borderBottomLeftRadius: after,
borderBottomRightRadius: after,
}
: {
borderTopLeftRadius: before,
borderBottomLeftRadius: before,
borderTopRightRadius: after,
borderBottomRightRadius: after,
};
}
interface TimedEventBlockProps {
title: string;
/** Start time for the second line; null leaves it out. */
timeLabel: string | null;
colors: EventBlockColors;
/** Page ground: a 1px ring in it keeps overlapping blocks apart. */
ringColor: string;
/** The event started on an earlier day (square top corners). */
continuesBefore: boolean;
/** The event runs into the next day (square bottom corners). */
continuesAfter: boolean;
/** Position and size in the day column. */
style: StyleProp<ViewStyle>;
onPress?: () => void;
}
/** A timed event in a day column. */
export function TimedEventBlock({
title,
timeLabel,
colors,
ringColor,
continuesBefore,
continuesAfter,
style,
onPress,
}: TimedEventBlockProps) {
const inactive = colors.border !== null;
return (
<Pressable
onPress={onPress}
style={[
styles.block,
corners('vertical', continuesBefore, continuesAfter, EVENT_RADIUS),
{ backgroundColor: colors.fill, borderColor: ringColor },
style,
]}
>
{inactive && (
// The calendar-coloured outline sits inside the ring.
<View
pointerEvents="none"
style={[
styles.outline,
corners('vertical', continuesBefore, continuesAfter, EVENT_RADIUS - 1),
{ borderColor: colors.border ?? undefined },
]}
/>
)}
<Text
style={[styles.blockTitle, { color: colors.text }, inactive && styles.struck]}
numberOfLines={1}
>
{title}
</Text>
{timeLabel !== null && (
<Text style={[styles.blockTime, { color: colors.text }]} numberOfLines={1}>
{timeLabel}
</Text>
)}
</Pressable>
);
}
interface AllDayEventBarProps {
title: string;
colors: EventBlockColors;
/** The event runs in from the week before (square left corners). */
continuesBefore: boolean;
/** The event runs on into the week after (square right corners). */
continuesAfter: boolean;
/** Position and size in the all-day strip. */
style: StyleProp<ViewStyle>;
onPress?: () => void;
}
/** An all-day or multi-day event in the strip above the grid. */
export function AllDayEventBar({
title,
colors,
continuesBefore,
continuesAfter,
style,
onPress,
}: AllDayEventBarProps) {
const inactive = colors.border !== null;
return (
<Pressable
onPress={onPress}
style={[
styles.bar,
corners('horizontal', continuesBefore, continuesAfter, EVENT_RADIUS),
{ backgroundColor: colors.fill },
inactive && [styles.barInactive, { borderColor: colors.border ?? undefined }],
style,
]}
>
<Text style={[styles.barTitle, { color: colors.text }]} numberOfLines={1}>
{continuesBefore ? '… ' : ''}
<Text style={inactive && styles.struck}>{title}</Text>
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
block: {
position: 'absolute',
borderWidth: 1,
paddingHorizontal: 3,
paddingVertical: 1,
overflow: 'hidden',
},
outline: { ...StyleSheet.absoluteFillObject, borderWidth: 1 },
blockTitle: { fontSize: 12, lineHeight: 15, fontWeight: '500' },
blockTime: { fontSize: 10.5, lineHeight: 13, fontWeight: '400', opacity: 0.85 },
struck: { textDecorationLine: 'line-through' },
bar: {
position: 'absolute',
paddingHorizontal: 4,
justifyContent: 'center',
overflow: 'hidden',
},
// The outline takes the place of a pixel of padding.
barInactive: { borderWidth: 1, paddingHorizontal: 3 },
barTitle: { fontSize: 12, lineHeight: 15, fontWeight: '500' },
});
+44 -21
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Clock, MapPin, Users } from 'lucide-react-native';
import { Clock, MapPin, Users } from '../icons';
import { format } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -8,50 +8,63 @@ import { useColors } from '../../theme/colors';
import {
eventTimeRange,
getEventColor,
isTimedEventFullDayOnDate,
timePattern,
type TimeFormat,
} from '../../lib/calendar-utils';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { isInactiveEvent } from '../../lib/calendar-participants';
interface EventCardProps {
event: CalendarEvent;
calendars: Calendar[];
timeFormat?: TimeFormat;
/** The user's addresses, to draw events they declined as inactive. */
currentUserEmails?: string[];
onPress?: (event: CalendarEvent) => void;
onLongPress?: (event: CalendarEvent) => void;
/** The day the row is listed under: a timed event filling it reads as all day. */
day?: Date;
}
function participantCount(event: CalendarEvent): number {
return event.participants ? Object.keys(event.participants).length : 0;
}
export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) {
/**
* An event as an agenda row (repos/branding/APP.md): a dot in the calendar
* colour before the title, no card and no bar. Declined and cancelled events
* get a hollow dot and a struck-through, muted title.
*/
export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress, day }: EventCardProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const color = getEventColor(event, calendars);
const { start, end, allDay } = eventTimeRange(event);
const range = eventTimeRange(event);
const { start, end } = range;
const allDay = range.allDay || (!!day && isTimedEventFullDayOnDate(event, day));
const fmt = timePattern(timeFormat);
const time = allDay
? t('calendar.events.all_day', 'All day')
: `${format(start, fmt, { locale })} – ${format(end, fmt, { locale })}`;
const count = participantCount(event);
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const cancelled = event.status === 'cancelled';
const inactive = isInactiveEvent(event, currentUserEmails);
return (
<Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => onPress?.(event)}
onLongPress={() => onLongPress?.(event)}
>
<View style={[styles.colorBar, { backgroundColor: color }]} />
<View style={[styles.dot, inactive ? [styles.dotHollow, { borderColor: color }] : { backgroundColor: color }]} />
<View style={styles.body}>
<View style={styles.headerRow}>
<Text style={[styles.title, cancelled && styles.titleCancelled]} numberOfLines={1}>
<Text style={[styles.title, inactive && styles.titleInactive]} numberOfLines={1}>
{event.title || t('calendar.events.no_title', '(No title)')}
</Text>
{event.showWithoutTime && (
{allDay && (
<View style={styles.allDayBadge}>
<Text style={styles.allDayText}>{t('calendar.events.all_day', 'All day')}</Text>
</View>
@@ -62,7 +75,7 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }
</View>
)}
</View>
{!event.showWithoutTime && (
{!allDay && (
<View style={styles.detailRow}>
<Clock size={12} color={c.textMuted} />
<Text style={styles.detailText}>{time}</Text>
@@ -89,21 +102,31 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }
);
}
const EVENT_DOT = 9;
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
card: {
// Bleeds into the list's side padding so the dot lines up with the day
// headers while the pressed highlight keeps some room around the text.
row: {
flexDirection: 'row',
backgroundColor: c.card,
borderTopRightRadius: radius.lg,
borderBottomRightRadius: radius.lg,
marginBottom: spacing.sm,
overflow: 'hidden',
borderWidth: 1,
borderColor: c.border,
alignItems: 'flex-start',
gap: spacing.sm,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.sm,
marginHorizontal: -spacing.sm,
borderRadius: radius.sm,
},
cardPressed: { backgroundColor: c.surfaceHover },
colorBar: { width: 3 },
body: { flex: 1, padding: spacing.md, gap: 4 },
rowPressed: { backgroundColor: c.surfaceHover },
// Centred on the title's line (bodyMedium, 20px).
dot: {
width: EVENT_DOT,
height: EVENT_DOT,
borderRadius: EVENT_DOT / 2,
marginTop: (typography.bodyMedium.lineHeight - EVENT_DOT) / 2,
},
dotHollow: { borderWidth: 1.5 },
body: { flex: 1, gap: 4 },
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
@@ -111,7 +134,7 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm,
},
title: { ...typography.bodyMedium, color: c.text, flex: 1 },
titleCancelled: { textDecorationLine: 'line-through', color: c.textMuted },
titleInactive: { textDecorationLine: 'line-through', color: c.mutedForeground },
allDayBadge: {
backgroundColor: c.primaryBg,
paddingHorizontal: 8,
+68 -16
View File
@@ -3,15 +3,19 @@ import {
Animated,
Dimensions,
Easing,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { openExternalUrl } from '../../lib/open-url';
import { toast } from '../../stores/toast-store';
import { ToastHost } from '../ToastHost';
import { splitTextLinks } from '../../lib/linkify-text';
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from '../../lib/event-links';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
AlignLeft,
@@ -30,7 +34,7 @@ import {
Users,
Video,
X,
} from 'lucide-react-native';
} from '../icons';
import { format, type Locale } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -52,9 +56,13 @@ import {
import { getEventEditability } from '../../lib/calendar-editability';
import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence';
import type { TranslateFn } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { SafeAreaModal } from '../SafeAreaModal';
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
@@ -88,9 +96,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate()
) {
return format(start, 'EEEE, MMM d, yyyy', opts);
return formatCalendarDate(start, locale.code, 'longWeekdayMonthDayYear');
}
return `${format(start, 'MMM d', opts)} – ${format(end, 'MMM d, yyyy', opts)}`;
return `${formatCalendarDate(start, locale.code, 'monthDay')} – ${formatCalendarDate(end, locale.code, 'monthDayYear')}`;
}
const tp = timePattern(timeFormat);
const sameDay =
@@ -98,9 +106,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate();
if (sameDay) {
return `${format(start, 'EEE, MMM d', opts)} · ${format(start, tp, opts)} – ${format(end, tp, opts)}`;
return `${formatCalendarDate(start, locale.code, 'weekdayMonthDay')} · ${format(start, tp, opts)} – ${format(end, tp, opts)}`;
}
return `${format(start, `MMM d, ${tp}`, opts)} – ${format(end, `MMM d, ${tp}`, opts)}`;
return `${formatCalendarDate(start, locale.code, 'monthDay')}, ${format(start, tp, opts)} – ${formatCalendarDate(end, locale.code, 'monthDay')}, ${format(end, tp, opts)}`;
}
function recurrenceLabel(event: CalendarEvent, t: TranslateFn, locale: Locale): string | null {
@@ -135,6 +143,8 @@ export function EventDetailSheet({
// name) render with the contact card's name instead of the raw email.
const resolveContactName = useContactNameResolver();
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const visible = !!event;
const dragHandlers = useSheetDrag({
@@ -184,8 +194,11 @@ export function EventDetailSheet({
const recurrence = recurrenceLabel(event, t, locale);
const reminders = alertsToReminders(event.alerts);
const participants = getParticipantList(event, { resolveName: resolveContactName });
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
const location = primaryLocationName(event);
// Invitations (Teams above all) leave virtualLocations empty and bury the
// join URL in the description; surface it as the meeting link anyway.
const meeting = findMeetingLink(event);
const videoUri = meeting?.uri;
const isCancelled = event.status === 'cancelled';
// Can the signed-in user RSVP? Only when they appear as a non-organizer
@@ -207,6 +220,23 @@ export function EventDetailSheet({
);
const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined;
const openLocation = () => {
if (!location) return;
const action = locationAction(location, meeting);
if (action.kind === 'url') {
void openExternalUrl(action.uri, { confirm: true });
} else {
void openExternalUrl(mapsUrl(action.query, Platform.OS));
}
};
const copyLocation = () => {
if (!location) return;
void Clipboard.setStringAsync(location).then(() => {
toast.success(t('notifications.copied_to_clipboard', 'Copied to clipboard'));
}).catch(() => {});
};
const doRsvp = async (status: RsvpStatus) => {
if (!onRsvp || !myParticipantId || rsvpBusy) return;
setRsvpBusy(true);
@@ -218,7 +248,7 @@ export function EventDetailSheet({
};
return (
<Modal
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
@@ -229,7 +259,7 @@ export function EventDetailSheet({
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
@@ -247,7 +277,7 @@ export function EventDetailSheet({
<Pressable
onPress={onClose}
style={styles.closeBtn}
hitSlop={8}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
@@ -263,7 +293,21 @@ export function EventDetailSheet({
>
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
{location ? (
<DetailRow icon={<MapPin size={16} color={c.textMuted} />} text={location} />
<View style={styles.detailRow}>
<View style={styles.detailIcon}>
<MapPin size={16} color={c.textMuted} />
</View>
<Text
style={[styles.detailText, styles.detailLink]}
numberOfLines={3}
onPress={openLocation}
onLongPress={copyLocation}
accessibilityRole="link"
accessibilityHint={t('calendar.detail.location_hint', 'Opens the location; long-press to copy it')}
>
{location}
</Text>
</View>
) : null}
{videoUri ? (
<View style={styles.detailRow}>
@@ -274,7 +318,10 @@ export function EventDetailSheet({
style={styles.joinBtn}
onPress={() => { void openExternalUrl(videoUri, { confirm: true }); }}
>
<Text style={styles.joinBtnText}>{t('calendar.detail.open_link', 'Open link')}</Text>
<Text style={styles.joinBtnText}>
{t('calendar.detail.open_link', 'Open link')}
{meeting?.provider ? ` · ${meeting.provider}` : ''}
</Text>
</Pressable>
</View>
) : null}
@@ -414,7 +461,10 @@ export function EventDetailSheet({
)}
</SafeAreaView>
</Animated.View>
</Modal>
{/* The screen's own toast host sits under this Modal; this one shows
the copy confirmation above the sheet. */}
<ToastHost />
</SafeAreaModal>
);
}
@@ -568,7 +618,9 @@ function makeStyles(c: ThemePalette) {
borderTopWidth: 1,
borderTopColor: c.border,
},
sheetSafe: { paddingTop: spacing.sm },
// The body shrinks to what is left under the sheet's max height, so the
// action row stays on screen in a short (landscape) window.
sheetSafe: { paddingTop: spacing.sm, flexShrink: 1 },
handleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
@@ -595,7 +647,7 @@ function makeStyles(c: ThemePalette) {
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
closeBtn: { padding: 4 },
body: { maxHeight: 400 },
body: { flexShrink: 1 },
bodyContent: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
+29 -28
View File
@@ -1,15 +1,13 @@
import React from 'react';
import {
Alert as AlertDialog,
Modal,
View,
Text,
ScrollView,
TextInput,
Pressable,
StyleSheet,
Switch,
Platform,
Alert as NativeAlert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import DateTimePicker from '@react-native-community/datetimepicker';
@@ -26,7 +24,7 @@ import {
Trash2,
AlignLeft,
AlertTriangle,
} from 'lucide-react-native';
} from '../icons';
import { addHours, addMinutes, format } from 'date-fns';
import type {
Alert,
@@ -48,6 +46,7 @@ import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from
import { useSettingsStore } from '../../stores/settings-store';
import { canCreateEventsIn } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format';
import {
alertsToReminders,
reminderFieldsToSave,
@@ -71,6 +70,9 @@ import { Button } from '..';
import { ParticipantInput } from './ParticipantInput';
import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { RecurrenceEditor } from './RecurrenceEditor';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { SafeAreaModal } from '../SafeAreaModal';
import ToggleSwitch from '../ToggleSwitch';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
@@ -107,9 +109,11 @@ interface EventModalProps {
onClose: () => void;
}
// A time already on the hour or half hour (a tapped slot) stays as it is.
function nextHalfHour(d: Date): Date {
const date = new Date(d);
const min = date.getMinutes();
if (min % 30 === 0 && date.getSeconds() === 0 && date.getMilliseconds() === 0) return date;
const add = min < 30 ? 30 - min : 60 - min;
date.setMinutes(date.getMinutes() + add, 0, 0);
return date;
@@ -379,8 +383,9 @@ export function EventModal({
});
onClose();
} catch (err) {
AlertDialog.alert(
t('calendar.notifications.event_error', 'Something went wrong'),
// The editor stays open with everything typed, and says why.
NativeAlert.alert(
t('calendar.notifications.event_error', 'Failed to save event'),
participantLimitMessage(err, t) ?? (err instanceof Error ? err.message : undefined),
);
} finally {
@@ -430,7 +435,7 @@ export function EventModal({
);
return (
<Modal
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
animationType="slide"
presentationStyle="fullScreen"
@@ -470,7 +475,7 @@ export function EventModal({
value={title}
onChangeText={setTitle}
placeholder={t('calendar.form.title', 'Title')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
style={styles.titleInput}
/>
@@ -526,12 +531,10 @@ export function EventModal({
<Section icon={<Clock size={16} color={c.textMuted} />} label={t('calendar.form.time', 'Time')}>
<View style={styles.allDayRow}>
<Text style={styles.fieldText}>{t('calendar.form.all_day_event', 'All-day event')}</Text>
<Switch
<ToggleSwitch
value={allDay}
onValueChange={setAllDay}
accessibilityLabel={t('calendar.form.all_day_event', 'All-day event')}
thumbColor={allDay ? c.primary : c.textMuted}
trackColor={{ false: c.surface, true: c.primaryBg }}
/>
</View>
@@ -542,7 +545,7 @@ export function EventModal({
style={styles.dateBtn}
onPress={() => setShowStartDate(true)}
>
<Text style={styles.dateText}>{format(start, 'EEE, MMM d', { locale })}</Text>
<Text style={styles.dateText}>{formatCalendarDate(start, locale.code, 'weekdayMonthDay')}</Text>
</Pressable>
{!allDay && (
<Pressable
@@ -562,7 +565,7 @@ export function EventModal({
style={styles.dateBtn}
onPress={() => setShowEndDate(true)}
>
<Text style={styles.dateText}>{format(end, 'EEE, MMM d', { locale })}</Text>
<Text style={styles.dateText}>{formatCalendarDate(end, locale.code, 'weekdayMonthDay')}</Text>
</Pressable>
{!allDay && (
<Pressable
@@ -684,7 +687,7 @@ export function EventModal({
keyboardType="number-pad"
style={styles.customAmountInput}
placeholder="30"
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
/>
<View style={styles.customUnits}>
{(['minutes', 'hours', 'days', 'weeks'] as ReminderUnit[]).map((unit) => (
@@ -719,7 +722,7 @@ export function EventModal({
value={location}
onChangeText={setLocation}
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
style={styles.fieldButton}
/>
</Section>
@@ -729,7 +732,7 @@ export function EventModal({
value={videoUrl}
onChangeText={setVideoUrl}
placeholder="https://meet.example.com/…"
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
@@ -745,7 +748,7 @@ export function EventModal({
value={description}
onChangeText={setDescription}
placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
multiline
style={styles.descriptionInput}
/>
@@ -782,12 +785,10 @@ export function EventModal({
<Text style={[styles.fieldText, { flex: 1 }]}>
{t('calendar.participants.send_invitations', 'Send invitations to participants')}
</Text>
<Switch
<ToggleSwitch
value={sendInvitations}
onValueChange={setSendInvitations}
accessibilityLabel={t('calendar.participants.send_invitations', 'Send invitations to participants')}
thumbColor={sendInvitations ? c.primary : c.textMuted}
trackColor={{ false: c.surface, true: c.primaryBg }}
/>
</View>
)}
@@ -868,7 +869,7 @@ export function EventModal({
/>
)}
</SafeAreaView>
</Modal>
</SafeAreaModal>
);
}
@@ -939,7 +940,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
justifyContent: 'center',
},
fieldText: { ...typography.body, color: c.text },
@@ -958,7 +959,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
},
reminderRemove: { padding: 2 },
customReminderRow: {
@@ -967,7 +968,7 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
},
customAmountInput: {
...typography.body,
@@ -1003,7 +1004,7 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.popover,
overflow: 'hidden',
},
popoverRow: {
@@ -1025,7 +1026,7 @@ function makeStyles(c: ThemePalette) {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
borderWidth: 1,
borderColor: c.border,
},
@@ -1037,7 +1038,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
},
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
datePickers: { flexDirection: 'row', gap: spacing.sm },
@@ -1058,7 +1059,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
color: c.text,
...typography.body,
textAlignVertical: 'top',
+10 -5
View File
@@ -1,9 +1,9 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, Alert, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from 'lucide-react-native';
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from '../icons';
import { format, parseISO } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { uploadBlob } from '../../api/blob';
@@ -17,6 +17,9 @@ import { ImportRefusedError, type ImportResult } from '../../stores/calendar-sto
import { useLocaleStore } from '../../stores/locale-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
import type { Locale } from 'date-fns';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { SafeAreaModal } from '../SafeAreaModal';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
@@ -74,6 +77,8 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
const [result, setResult] = React.useState<ImportResult | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const resetState = React.useCallback(() => {
@@ -184,11 +189,11 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>{t('calendar.import.title', 'Import Calendar')}</Text>
@@ -374,7 +379,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
)}
</SafeAreaView>
</Animated.View>
</Modal>
</SafeAreaModal>
);
}
@@ -1,9 +1,9 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator,
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, TextInput, ActivityIndicator, Alert,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from 'lucide-react-native';
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from '../icons';
import { formatDistanceToNow } from 'date-fns';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -18,6 +18,9 @@ import {
type CalendarSubscription,
} from '../../stores/calendar-subscriptions-store';
import { jmapClient } from '../../api/jmap-client';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { SafeAreaModal } from '../SafeAreaModal';
// The webmail's refresh intervals and their labels.
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
@@ -80,6 +83,8 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
};
const slideY = React.useRef(new Animated.Value(900)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
@@ -136,11 +141,11 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Rss size={20} color={c.text} />
@@ -280,7 +285,24 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
)}
</Pressable>
<Pressable
onPress={() => { void removeSubscription(sub.id); }}
// Unsubscribing also deletes the calendar and its events.
onPress={() => Alert.alert(
t('calendar.subscription.unsubscribe', 'Unsubscribe'),
t('calendar.subscription.confirm_delete', 'Unsubscribe from "{name}"? The calendar and all its events will be removed.', { name: sub.name }),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{
text: t('calendar.subscription.unsubscribe', 'Unsubscribe'),
style: 'destructive',
onPress: () => {
removeSubscription(sub.id).catch((err: unknown) => Alert.alert(
t('calendar.subscription.delete_error', 'Failed to remove subscription'),
err instanceof Error ? err.message : undefined,
));
},
},
],
)}
hitSlop={6}
style={styles.subBtn}
accessibilityRole="button"
@@ -297,7 +319,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
<View style={{ height: insets.bottom }} />
</SafeAreaView>
</Animated.View>
</Modal>
</SafeAreaModal>
);
}
+144
View File
@@ -0,0 +1,144 @@
import React from 'react';
import {
FlatList,
ScrollView,
StyleSheet,
View,
useWindowDimensions,
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from 'react-native';
import { addMonths, startOfMonth } from 'date-fns';
import type { Calendar } from '../../api/types';
import type { ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import type { EventDayIndex } from '../../lib/calendar-utils';
import { MONTH_ROW_HEIGHT, MonthView } from './MonthView';
type WeekStart = 0 | 1 | 6;
/** Months either side of the one the panel opens on. */
const MONTHS_EACH_SIDE = 24;
/** Six week rows plus the weekday initials. */
const PANEL_HEIGHT = MONTH_ROW_HEIGHT * 6 + 22;
interface MonthDropdownProps {
/** The month the panel opens on. */
month: Date;
selectedDate: Date;
eventsByDay: EventDayIndex;
calendars: Calendar[];
weekStartsOn: WeekStart;
/** Reports the month swiped to, so the title can follow it. */
onMonthChange: (month: Date) => void;
onSelectDate: (date: Date) => void;
}
/**
* The month that drops down under the calendar title: swipe sideways
* between months, tap a day to go there.
*/
export function MonthDropdown({
month,
selectedDate,
eventsByDay,
calendars,
weekStartsOn,
onMonthChange,
onSelectDate,
}: MonthDropdownProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
// Pages are as wide as the panel (the window, or a pane on a tablet) and
// re-measure on rotation. In a short window the panel takes at most about
// half of it and its month scrolls inside.
const [measuredWidth, setMeasuredWidth] = React.useState(0);
const width = measuredWidth || windowWidth;
const panelHeight = Math.min(PANEL_HEIGHT, Math.round(windowHeight * 0.55));
// The months are fixed while the panel is open; it remounts on each open.
const [months] = React.useState(() => {
const first = addMonths(startOfMonth(month), -MONTHS_EACH_SIDE);
return Array.from({ length: MONTHS_EACH_SIDE * 2 + 1 }, (_, i) => addMonths(first, i));
});
const lastIndexRef = React.useRef(MONTHS_EACH_SIDE);
const handleMomentumEnd = React.useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const index = Math.round(e.nativeEvent.contentOffset.x / width);
if (index === lastIndexRef.current || !months[index]) return;
lastIndexRef.current = index;
onMonthChange(months[index]);
},
[width, months, onMonthChange],
);
const renderItem = React.useCallback(
({ item }: ListRenderItemInfo<Date>) => (
<ScrollView
style={{ width, height: panelHeight }}
scrollEnabled={panelHeight < PANEL_HEIGHT}
showsVerticalScrollIndicator={false}
>
<MonthView
currentDate={item}
selectedDate={selectedDate}
events={[]}
eventsByDay={eventsByDay}
calendars={calendars}
weekStartsOn={weekStartsOn}
onSelectDate={onSelectDate}
/>
</ScrollView>
),
[width, panelHeight, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate],
);
const getItemLayout = React.useCallback(
(_: ArrayLike<Date> | null | undefined, i: number) => ({ length: width, offset: width * i, index: i }),
[width],
);
return (
<View
style={styles.panel}
onLayout={(e) => {
const w = Math.round(e.nativeEvent.layout.width);
if (w > 0 && w !== measuredWidth) setMeasuredWidth(w);
}}
>
<FlatList
// A new width remounts the pager on the month it was showing.
key={width}
horizontal
pagingEnabled
data={months}
keyExtractor={monthKey}
renderItem={renderItem}
getItemLayout={getItemLayout}
initialScrollIndex={lastIndexRef.current}
onMomentumScrollEnd={handleMomentumEnd}
showsHorizontalScrollIndicator={false}
initialNumToRender={1}
windowSize={3}
style={{ height: panelHeight }}
/>
</View>
);
}
function monthKey(month: Date): string {
return `${month.getFullYear()}-${month.getMonth()}`;
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
panel: {
backgroundColor: c.background,
borderBottomWidth: 1,
borderBottomColor: c.border,
paddingTop: 4,
},
});
}
+53 -12
View File
@@ -2,6 +2,7 @@ import React from 'react';
import {
FlatList,
View,
type LayoutChangeEvent,
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
@@ -17,6 +18,7 @@ import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
import {
dayIndexIn,
monthFocusRow,
monthRowGeometry,
monthKeyOf,
monthMask,
sampledRow,
@@ -26,8 +28,7 @@ import {
import { useCalendarLocale } from '../../lib/calendar-locale';
import { displayNow } from '../../lib/calendar-timezone';
import {
MONTH_ROW_HEIGHT,
MONTH_ROW_HEIGHT_CHIPS,
MONTH_ROW_HEIGHT_FULL_MIN,
MonthWeekRow,
MonthWeekdayHeader,
useMonthStyles,
@@ -53,6 +54,8 @@ interface MonthScrollViewProps {
showWeekNumbers?: boolean;
showTimeInMonthView?: boolean;
timeFormat?: TimeFormat;
/** The user's addresses, to draw events they declined as inactive. */
currentUserEmails?: string[];
/** Widen the window at the top; omitted once the limit is reached. */
onExtendStart?: () => void;
/** Widen the window at the bottom; omitted once the limit is reached. */
@@ -83,6 +86,7 @@ function MonthScrollViewInner({
showWeekNumbers = false,
showTimeInMonthView = false,
timeFormat,
currentUserEmails,
onExtendStart,
onExtendEnd,
onVisibleDateChange,
@@ -114,8 +118,16 @@ function MonthScrollViewInner({
}, [window]);
const weeksRef = React.useRef(weeks);
weeksRef.current = weeks;
const rowHeight = showTimeInMonthView ? MONTH_ROW_HEIGHT_CHIPS : MONTH_ROW_HEIGHT;
const viewportHeight = rowHeight * VISIBLE_ROWS;
// Six weeks fill the space under the weekday initials, like Google
// Calendar's month; rows wait for that space to be measured.
const [listHeight, setListHeight] = React.useState(0);
const handleListLayout = React.useCallback((e: LayoutChangeEvent) => {
const height = e.nativeEvent.layout.height;
if (height > 0) setListHeight(height);
}, []);
// A phone in landscape has room for three or four weeks, not six.
const { rowHeight, visibleRows } = monthRowGeometry(listHeight, MONTH_ROW_HEIGHT_FULL_MIN, VISIBLE_ROWS);
const viewportHeight = listHeight;
// The month in focus: set by navigation, then by what scrolls under the
// sample line.
@@ -124,16 +136,28 @@ function MonthScrollViewInner({
const onVisibleRef = React.useRef(onVisibleDateChange);
onVisibleRef.current = onVisibleDateChange;
const offsetRef = React.useRef(0);
// Row at the top of the viewport; a change of row height (the "show time"
// setting) remounts the list there.
const topRowRef = React.useRef<number | null>(null);
// Week at the top of the viewport; a change of row height (the "show time"
// setting, a rotation) remounts the list there. Kept as the week, not its
// index, because weeks added above shift the indices.
const topWeekRef = React.useRef<string | null>(null);
// A (re)mounted list reports offset 0 before its initial scroll lands; that
// report would set the month to the window's first one (a rotation jumped
// back a month). Until the list reaches its start row, or the user drags,
// scroll reports don't move the month.
const settledRef = React.useRef(false);
const handleScroll = React.useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const offset = e.nativeEvent.contentOffset.y;
if (!settledRef.current) {
const expected = (initialRef.current?.row ?? 0) * rowHeight;
if (Math.abs(offset - expected) > rowHeight / 2) return;
settledRef.current = true;
}
offsetRef.current = offset;
topRowRef.current = Math.round(offset / rowHeight);
const list = weeksRef.current;
const top = list[Math.max(0, Math.min(list.length - 1, Math.round(offset / rowHeight)))];
if (top) topWeekRef.current = dayKey(top[0]);
const row = sampledRow(offset, viewportHeight, rowHeight, list.length);
const days = list[row];
if (!days) return;
@@ -148,15 +172,18 @@ function MonthScrollViewInner({
[viewportHeight, rowHeight],
);
const markSettled = React.useCallback(() => { settledRef.current = true; }, []);
// Navigation inside the window scrolls the focused month's first week to
// the top. The list mounts there already, so its own nonce needs nothing.
const listRef = React.useRef<FlatList<Date[]>>(null);
const handledNonceRef = React.useRef(focus.nonce);
const initialRef = React.useRef<{ rowHeight: number; row: number } | null>(null);
if (initialRef.current === null || initialRef.current.rowHeight !== rowHeight) {
settledRef.current = false;
initialRef.current = {
rowHeight,
row: topRowRef.current ?? monthFocusRow(window, focus.date, weeks.length, opts),
row: rowOfWeek(weeks, topWeekRef.current) ?? monthFocusRow(window, focus.date, weeks.length, opts),
};
}
React.useEffect(() => {
@@ -191,7 +218,9 @@ function MonthScrollViewInner({
showWeekNumbers={showWeekNumbers}
showTimeInMonthView={showTimeInMonthView}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
labelMonths
variant="full"
height={rowHeight}
locale={locale}
styles={styles}
@@ -209,6 +238,7 @@ function MonthScrollViewInner({
showWeekNumbers,
showTimeInMonthView,
timeFormat,
currentUserEmails,
rowHeight,
locale,
styles,
@@ -227,8 +257,10 @@ function MonthScrollViewInner({
);
return (
<View style={styles.grid}>
<View style={styles.fullGrid}>
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
<View style={styles.fullRows} onLayout={handleListLayout}>
{listHeight > 0 && (
<FlatList
key={rowHeight}
ref={listRef}
@@ -248,14 +280,17 @@ function MonthScrollViewInner({
onEndReached={onExtendEnd}
onEndReachedThreshold={0.5}
onScroll={handleScroll}
onScrollBeginDrag={markSettled}
scrollEventThrottle={32}
snapToInterval={rowHeight}
decelerationRate="fast"
showsVerticalScrollIndicator={false}
initialNumToRender={VISIBLE_ROWS + 2}
maxToRenderPerBatch={VISIBLE_ROWS}
initialNumToRender={visibleRows + 2}
maxToRenderPerBatch={visibleRows}
windowSize={5}
/>
)}
</View>
</View>
);
}
@@ -265,3 +300,9 @@ export const MonthScrollView = React.memo(MonthScrollViewInner);
function weekKey(days: Date[]): string {
return dayKey(days[0]);
}
function rowOfWeek(weeks: Date[][], key: string | null): number | null {
if (!key) return null;
const i = weeks.findIndex((days) => dayKey(days[0]) === key);
return i >= 0 ? i : null;
}
+246 -18
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { View, Text, StyleSheet, Pressable, useWindowDimensions } from 'react-native';
import {
addDays,
endOfMonth,
@@ -12,18 +12,21 @@ import {
type Locale,
} from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { componentSizes, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
buildEventDayIndex,
eventsOnDayFromIndex,
getEventColor,
getEventStartDate,
isTimedEventFullDayOnDate,
timePattern,
type EventDayIndex,
type TimeFormat,
} from '../../lib/calendar-utils';
import { dayIndexIn, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll';
import { dayIndexIn, monthChipShowsTime, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll';
import { isInactiveEvent } from '../../lib/calendar-participants';
import { eventBlockColors } from '../../lib/event-colors';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { displayNow } from '../../lib/calendar-timezone';
@@ -33,6 +36,12 @@ type WeekStart = 0 | 1 | 6;
// scrolling month uses them as fixed row heights.
export const MONTH_ROW_HEIGHT = 54;
export const MONTH_ROW_HEIGHT_CHIPS = 74;
// Smallest week row of the full-screen month grid.
export const MONTH_ROW_HEIGHT_FULL_MIN = 72;
/** Smallest row of the paged month grid, which can't scroll to hidden weeks. */
const MONTH_ROW_HEIGHT_FILL_MIN = 36;
// Corner radius of event bars (repos/branding/APP.md).
const EVENT_RADIUS = 2;
interface MonthViewProps {
currentDate: Date;
@@ -44,7 +53,11 @@ interface MonthViewProps {
showWeekNumbers?: boolean;
// Render compact event chips (title + start time) instead of dots (#666).
showTimeInMonthView?: boolean;
/** Fill the available height with the full month grid. */
fill?: boolean;
timeFormat?: TimeFormat;
/** The user's addresses, to draw events they declined as inactive. */
currentUserEmails?: string[];
onSelectDate: (date: Date) => void;
onLongPressDate?: (date: Date) => void;
}
@@ -101,10 +114,18 @@ export interface MonthWeekRowProps {
showWeekNumbers: boolean;
showTimeInMonthView: boolean;
timeFormat?: TimeFormat;
/** The user's addresses, to draw events they declined as inactive. */
currentUserEmails?: string[];
/** Mark the first day of a month with the month's name (continuous scrolling). */
labelMonths?: boolean;
/** Fixed row height (continuous scrolling); natural height otherwise. */
height?: number;
/**
* 'full': the month fills the screen, with grid
* lines and an event chip per event. 'compact': numbers with event dots
* (the month under the title).
*/
variant?: 'compact' | 'full';
locale: Locale;
styles: MonthStyles;
onSelectDate: (date: Date) => void;
@@ -122,13 +143,41 @@ function MonthWeekRowInner({
showWeekNumbers,
showTimeInMonthView,
timeFormat,
currentUserEmails,
labelMonths = false,
height,
variant = 'compact',
locale,
styles,
onSelectDate,
onLongPressDate,
}: MonthWeekRowProps) {
const c = useColors();
const { width } = useWindowDimensions();
const chipTime = monthChipShowsTime(true, width / 7);
if (variant === 'full') {
return (
<FullWeekRow
days={days}
activeMask={activeMask}
todayIndex={todayIndex}
index={index}
calendars={calendars}
weekStartsOn={weekStartsOn}
showWeekNumbers={showWeekNumbers}
showTimeInMonthView={showTimeInMonthView}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
labelMonths={labelMonths}
height={height ?? MONTH_ROW_HEIGHT_FULL_MIN}
locale={locale}
styles={styles}
colors={c}
onSelectDate={onSelectDate}
onLongPressDate={onLongPressDate}
/>
);
}
return (
<View style={[styles.weekRow, height !== undefined && { height, overflow: 'hidden' }]}>
{showWeekNumbers && (
@@ -197,17 +246,24 @@ function MonthWeekRowInner({
{dayEvents.length > 0 && showTimeInMonthView && (
<View style={styles.chipsCol}>
{visible.map((event, idx) => {
const color = getEventColor(event, calendars);
const cancelled = event.status === 'cancelled';
// Solid bars in the calendar colour; declined and
// cancelled events outlined (repos/branding/APP.md).
const inactive = isInactiveEvent(event, currentUserEmails);
const colors = eventBlockColors(getEventColor(event, calendars), inactive, c);
return (
<View key={`${event.id}-${idx}`} style={[styles.chip, { backgroundColor: color }]}>
<Text
style={[styles.chipText, cancelled && styles.chipTextCancelled]}
numberOfLines={1}
>
{event.showWithoutTime
? (event.title || '')
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`}
<View
key={`${event.id}-${idx}`}
style={[
styles.chip,
{ backgroundColor: colors.fill },
colors.border !== null && [styles.chipInactive, { borderColor: colors.border }],
]}
>
<Text style={[styles.chipText, { color: colors.text }]} numberOfLines={1}>
{event.showWithoutTime || !chipTime
? null
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `}
<Text style={inactive && styles.chipTextInactive}>{event.title || ''}</Text>
</Text>
</View>
);
@@ -226,6 +282,115 @@ function MonthWeekRowInner({
export const MonthWeekRow = React.memo(MonthWeekRowInner);
/** Day number area at the top of a full month cell. */
const FULL_NUMBER_HEIGHT = 24;
/** One event chip in a full month cell, with its gap. */
const FULL_CHIP_HEIGHT = 16;
const FULL_CHIP_GAP = 2;
/** Event chips that fit a full month cell of `height`, keeping a line for "+N" when needed. */
export function fullCellChipCount(height: number, eventCount: number): number {
const room = Math.max(0, Math.floor((height - FULL_NUMBER_HEIGHT - 2) / (FULL_CHIP_HEIGHT + FULL_CHIP_GAP)));
if (eventCount <= room) return eventCount;
return Math.max(0, room - 1);
}
// A week of the full-screen month grid: thin cell lines, the day number
// small at the top (today in a filled circle), then one filled chip per
// event and "+N" for those that don't fit.
function FullWeekRow({
days,
activeMask,
todayIndex,
index,
calendars,
weekStartsOn,
showWeekNumbers,
showTimeInMonthView,
timeFormat,
currentUserEmails,
labelMonths,
height,
locale,
styles,
colors: c,
onSelectDate,
onLongPressDate,
}: Omit<MonthWeekRowProps, 'selectedIndex' | 'variant'> & {
height: number;
colors: ThemePalette;
}) {
const { width } = useWindowDimensions();
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
return (
<View style={[styles.fullWeekRow, { height }]}>
{showWeekNumbers && (
<Text style={[styles.weekNumberCell, styles.fullWeekNumber]}>{weekNumberFor(days[0], weekStartsOn)}</Text>
)}
{days.map((d, i) => {
const sameMonth = (activeMask & (1 << i)) !== 0;
const today = i === todayIndex;
const dayEvents = eventsOnDayFromIndex(index, d);
const shown = fullCellChipCount(height, dayEvents.length);
const overflow = dayEvents.length - shown;
const label = labelMonths && d.getDate() === 1 ? format(d, 'MMM d', { locale }) : format(d, 'd');
return (
<Pressable
key={i}
style={({ pressed }) => [styles.fullCell, i === 0 && styles.fullCellFirst, pressed && styles.fullCellPressed]}
onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
accessibilityRole="button"
accessibilityLabel={format(d, 'PPPP', { locale })}
>
<View style={styles.fullNumberWrap}>
<View style={[styles.fullNumber, today && styles.fullNumberToday]}>
<Text
numberOfLines={1}
style={[
styles.fullNumberText,
!sameMonth && styles.dayTextMuted,
today && styles.fullNumberTextToday,
]}
>
{label}
</Text>
</View>
</View>
{dayEvents.slice(0, shown).map((event, idx) => {
const inactive = isInactiveEvent(event, currentUserEmails);
const colors = eventBlockColors(getEventColor(event, calendars), inactive, c);
// No time on events that fill the whole day.
const time = showTime && !event.showWithoutTime && !isTimedEventFullDayOnDate(event, d)
? `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `
: '';
return (
<View
key={`${event.id}-${idx}`}
style={[
styles.fullChip,
{ backgroundColor: colors.fill },
colors.border !== null && [styles.chipInactive, { borderColor: colors.border }],
]}
>
<Text
style={[styles.fullChipText, { color: colors.text }, inactive && styles.chipTextInactive]}
numberOfLines={1}
ellipsizeMode="clip"
>
{time}{event.title || ''}
</Text>
</View>
);
})}
{overflow > 0 && <Text style={styles.fullOverflow}>+{overflow}</Text>}
</Pressable>
);
})}
</View>
);
}
function MonthViewInner({
currentDate,
selectedDate,
@@ -235,11 +400,14 @@ function MonthViewInner({
weekStartsOn = 0,
showWeekNumbers = false,
showTimeInMonthView = false,
fill = false,
timeFormat,
currentUserEmails,
onSelectDate,
onLongPressDate,
}: MonthViewProps) {
const styles = useMonthStyles();
const [gridHeight, setGridHeight] = React.useState(0);
const { locale } = useCalendarLocale();
const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events),
@@ -262,12 +430,25 @@ function MonthViewInner({
// Today on a clock in the calendar's time zone.
const today = displayNow();
// Every week of the month has to show (there is no scrolling here), so in a
// short window (phone landscape) rows shrink below the usual minimum and
// their chips collapse into "+N".
const fullRowHeight = fill && gridHeight > 0
? Math.max(MONTH_ROW_HEIGHT_FILL_MIN, Math.floor(gridHeight / rows.length))
: undefined;
return (
<View style={styles.grid}>
<View style={fill ? styles.fullGrid : styles.grid}>
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
{rows.map((days) => (
<View
style={fill && styles.fullRows}
onLayout={fill ? (e) => setGridHeight(e.nativeEvent.layout.height) : undefined}
>
{(!fill || fullRowHeight !== undefined) && rows.map((days) => (
<MonthWeekRow
key={days[0].toISOString()}
variant={fill ? 'full' : 'compact'}
height={fullRowHeight}
days={days}
activeMask={monthMask(days, activeMonth)}
selectedIndex={dayIndexIn(days, selectedDate)}
@@ -278,12 +459,14 @@ function MonthViewInner({
showWeekNumbers={showWeekNumbers}
showTimeInMonthView={showTimeInMonthView}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
locale={locale}
styles={styles}
onSelectDate={onSelectDate}
onLongPressDate={onLongPressDate}
/>
))}
</View>
</View>
);
}
@@ -349,12 +532,17 @@ function makeStyles(c: ThemePalette) {
},
chipsCol: { width: '100%', gap: 1, marginTop: 2, alignItems: 'stretch' },
chip: {
borderRadius: radius.xs,
borderRadius: EVENT_RADIUS,
paddingHorizontal: 2,
paddingVertical: 1,
},
chipText: { color: c.textInverse, fontSize: 9, lineHeight: 11 },
chipTextCancelled: { textDecorationLine: 'line-through' },
// The 1px outline takes the place of a pixel of padding, so outlined and
// solid bars are the same size.
chipInactive: { borderWidth: 1, paddingHorizontal: 1, paddingVertical: 0 },
// Colour comes from eventBlockColors(): computed from the calendar colour,
// never a theme colour.
chipText: { fontSize: 9, lineHeight: 11, fontWeight: '500' },
chipTextInactive: { textDecorationLine: 'line-through' },
overflowText: {
color: c.textMuted,
fontSize: 9,
@@ -362,5 +550,45 @@ function makeStyles(c: ThemePalette) {
marginLeft: 1,
textAlign: 'center',
},
fullGrid: { flex: 1 },
fullRows: { flex: 1 },
fullWeekRow: {
flexDirection: 'row',
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: c.border,
},
fullWeekNumber: { paddingTop: 6 },
fullCell: {
flex: 1,
borderLeftWidth: StyleSheet.hairlineWidth,
borderLeftColor: c.border,
paddingHorizontal: 1,
overflow: 'hidden',
},
fullCellFirst: { borderLeftWidth: 0 },
fullCellPressed: { backgroundColor: c.surfaceHover },
fullNumberWrap: { height: FULL_NUMBER_HEIGHT, alignItems: 'center', justifyContent: 'center' },
fullNumber: {
minWidth: 22,
height: 22,
borderRadius: 11,
paddingHorizontal: 4,
alignItems: 'center',
justifyContent: 'center',
},
fullNumberToday: { backgroundColor: c.primary },
fullNumberText: { fontSize: 12, lineHeight: 16, fontWeight: '500', color: c.text },
fullNumberTextToday: { color: c.primaryForeground, fontWeight: '700' },
fullChip: {
height: FULL_CHIP_HEIGHT,
marginBottom: FULL_CHIP_GAP,
borderRadius: EVENT_RADIUS,
paddingHorizontal: 3,
justifyContent: 'center',
},
// 9px text on the phone's month bars (repos/branding/APP.md).
fullChipText: { fontSize: 9, lineHeight: 12, fontWeight: '500' },
fullOverflow: { fontSize: 10, lineHeight: 13, fontWeight: '500', color: c.textSecondary, paddingHorizontal: 3 },
});
}
+4 -1
View File
@@ -7,7 +7,7 @@ import {
TextInput,
ScrollView,
} from 'react-native';
import { X } from 'lucide-react-native';
import { X } from '../icons';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useContactsStore } from '../../stores/contacts-store';
@@ -107,6 +107,9 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
)}
<TextInput
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
value={draft}
onChangeText={setDraft}
placeholder={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
+1 -1
View File
@@ -1,7 +1,7 @@
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 { ChevronDown } from '../icons';
import { format, parseISO } from 'date-fns';
import type { RecurrenceRule } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -1,10 +1,11 @@
import React from 'react';
import { Modal, View, Text, Pressable, StyleSheet } from 'react-native';
import { Repeat, Trash2 } from 'lucide-react-native';
import { Repeat, Trash2 } from '../icons';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { Button } from '..';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all';
@@ -42,7 +43,7 @@ export function RecurrenceScopeDialog({
}, [visible]);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.backdrop}>
<View style={styles.dialog}>
<View style={styles.header}>
+21 -13
View File
@@ -4,7 +4,6 @@ import {
Animated,
Dimensions,
Easing,
Modal,
Platform,
Pressable,
ScrollView,
@@ -24,7 +23,7 @@ import {
Plus,
Trash2,
X,
} from 'lucide-react-native';
} from '../icons';
import { format, isBefore, startOfDay } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -41,7 +40,11 @@ import {
} from '../../lib/task-editor';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { SafeAreaModal } from '../SafeAreaModal';
type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue';
@@ -52,6 +55,8 @@ interface TasksSheetProps {
timeFormat?: TimeFormat;
// Task to open in the editor when the sheet appears (from a calendar chip).
initialTaskId?: string | null;
// Open straight on an empty editor (the calendar's "new task" button).
startNew?: boolean;
onClose: () => void;
onCreate: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void> | void;
onUpdate?: (id: string, changes: Partial<CalendarEvent>) => Promise<void> | void;
@@ -94,6 +99,7 @@ export function TasksSheet({
calendars,
timeFormat,
initialTaskId,
startNew = false,
onClose,
onCreate,
onUpdate,
@@ -104,6 +110,8 @@ export function TasksSheet({
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({
slideY,
@@ -132,10 +140,10 @@ export function TasksSheet({
setExpanded(true);
} else {
setEditor(emptyTaskEditor(defaultCalendarId));
setExpanded(false);
setExpanded(startNew);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, initialTaskId]);
}, [visible, initialTaskId, startNew]);
React.useEffect(() => {
if (visible && !editor.calendarId && defaultCalendarId) {
@@ -206,8 +214,8 @@ export function TasksSheet({
if (!d || !task.due) return null;
const hasTime = !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due);
return hasTime
? format(d, `EEE, MMM d · ${timePattern(timeFormat)}`, { locale })
: format(d, 'EEE, MMM d', { locale });
? `${formatCalendarDate(d, locale.code, 'weekdayMonthDay')} · ${format(d, timePattern(timeFormat), { locale })}`
: formatCalendarDate(d, locale.code, 'weekdayMonthDay');
};
const priorityColor = (level: PriorityLevel): string =>
@@ -227,12 +235,12 @@ export function TasksSheet({
];
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
@@ -244,7 +252,7 @@ export function TasksSheet({
<Pressable
onPress={onClose}
style={styles.closeBtn}
hitSlop={8}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
@@ -292,7 +300,7 @@ export function TasksSheet({
>
<CalendarIcon size={14} color={editor.due ? c.primary : c.textMuted} />
<Text style={[styles.chipText, editor.due && styles.chipTextActive]}>
{editor.due ? format(editor.due, 'MMM d', { locale }) : t('calendar.tasks.due_date', 'Due date')}
{editor.due ? formatCalendarDate(editor.due, locale.code, 'monthDay') : t('calendar.tasks.due_date', 'Due date')}
</Text>
{editor.due && (
<Pressable
@@ -472,7 +480,7 @@ export function TasksSheet({
}}
/>
)}
</Modal>
</SafeAreaModal>
);
}
@@ -522,7 +530,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
},
addBtn: {
width: 38,
@@ -544,7 +552,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
textAlignVertical: 'top',
},
editorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
+131 -74
View File
@@ -17,7 +17,7 @@ import {
} from 'react-native';
import { differenceInCalendarDays, format, type Locale } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useCalendarLocale } from '../../lib/calendar-locale';
import {
@@ -33,14 +33,18 @@ import {
} from '../../lib/calendar-utils';
import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
import { displayNow, displayNowMinutes } from '../../lib/calendar-timezone';
import { isInactiveEvent } from '../../lib/calendar-participants';
import { eventBlockColors } from '../../lib/event-colors';
import {
buildAllDaySegments,
columnsPerScreen,
headerColumnRange,
hourAtOffset,
timeGridFocusColumn,
windowDays,
type TimeGridMode,
} from '../../lib/calendar-time-grid';
import { AllDayEventBar, TIME_LINE_MIN_MINUTES, TimedEventBlock } from './EventBlock';
const HOUR_HEIGHT = 48;
const GRID_HEIGHT = 24 * HOUR_HEIGHT;
@@ -57,6 +61,8 @@ type TimeFormat = '12h' | '24h';
interface TimeGridScrollViewProps {
mode: TimeGridMode;
/** Days of the multi-day mode (`multiDayCount`). */
multiDays?: number;
/** The day the user navigated to; its week (week view) or itself (day view) is scrolled into view. */
focus: CalendarFocus;
/** The loaded window: one column per day. */
@@ -67,6 +73,8 @@ interface TimeGridScrollViewProps {
eventsByDay?: EventDayIndex;
weekStartsOn?: WeekStart;
timeFormat?: TimeFormat;
/** The user's addresses, to draw events they declined as inactive. */
currentUserEmails?: string[];
/** Widen the window at the start; omitted once the limit is reached. */
onExtendStart?: () => void;
/** Widen the window at the end; omitted once the limit is reached. */
@@ -90,6 +98,7 @@ interface TimeGridScrollViewProps {
*/
function TimeGridScrollViewInner({
mode,
multiDays = 3,
focus,
window,
selectedDate,
@@ -98,6 +107,7 @@ function TimeGridScrollViewInner({
eventsByDay,
weekStartsOn = 0,
timeFormat = '24h',
currentUserEmails,
onExtendStart,
onExtendEnd,
onVisibleDateChange,
@@ -113,7 +123,7 @@ function TimeGridScrollViewInner({
[eventsByDay, events],
);
const perScreen = mode === 'week' ? 7 : 1;
const perScreen = columnsPerScreen(mode, multiDays);
const { width: screenWidth } = useWindowDimensions();
const [rootWidth, setRootWidth] = React.useState(screenWidth);
const listWidth = Math.max(perScreen, rootWidth - GUTTER_WIDTH);
@@ -210,6 +220,7 @@ function TimeGridScrollViewInner({
listener: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const list = daysRef.current;
offsetXRef.current = e.nativeEvent.contentOffset.x;
if (e.nativeEvent.contentOffset.x > 0) scrolledSinceMountRef.current = true;
const col = Math.max(
0,
Math.min(list.length - 1, Math.round(e.nativeEvent.contentOffset.x / colWidthRef.current)),
@@ -232,7 +243,12 @@ function TimeGridScrollViewInner({
const visibleHandlerRef = React.useRef(onVisibleDateChange);
visibleHandlerRef.current = onVisibleDateChange;
const lastVisibleKeyRef = React.useRef<string | null>(null);
// Right after a (re)mount the list reports its first days as visible
// before the initial scroll lands; that would set the title to the window's
// first month. Reports wait until the list has scrolled at all.
const scrolledSinceMountRef = React.useRef(false);
const onViewableItemsChanged = React.useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => {
if (!scrolledSinceMountRef.current) return;
let first: Date | null = null;
for (const token of viewableItems) {
const day = token.item as Date;
@@ -282,6 +298,7 @@ function TimeGridScrollViewInner({
const handleLongPressAt = React.useCallback(
(day: Date, hour: number) => {
if (!onCreateAtTime) return;
setDraft(null);
// A display date (that hour in the calendar's zone); the editor turns
// it into the real instant when it saves (eventTimeFieldsToSave).
const date = new Date(day);
@@ -291,6 +308,14 @@ function TimeGridScrollViewInner({
[onCreateAtTime],
);
// A tap on an empty slot marks it with a "New event" block; tapping that
// block creates the event. Two taps, so a stray tap never opens the editor.
const [draft, setDraft] = React.useState<{ key: string; hour: number } | null>(null);
const handleTapAt = React.useCallback((day: Date, hour: number) => {
const key = dayKey(day);
setDraft((cur) => (cur && cur.key === key && cur.hour === hour ? null : { key, hour }));
}, []);
const todayKey = dayKey(displayNow());
const renderItem = React.useCallback(
({ item: day }: ListRenderItemInfo<Date>) => (
@@ -301,12 +326,19 @@ function TimeGridScrollViewInner({
nowMinutes={dayKey(day) === todayKey ? nowMinutes : -1}
calendars={calendars}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
colors={c}
styles={styles}
onSelectEvent={onSelectEvent}
onLongPressAt={onCreateAtTime ? handleLongPressAt : undefined}
onTapAt={onCreateAtTime ? handleTapAt : undefined}
draftHour={draft && draft.key === dayKey(day) ? draft.hour : -1}
/>
),
[layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, styles, onSelectEvent, onCreateAtTime, handleLongPressAt],
[
layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, currentUserEmails, c, styles,
onSelectEvent, onCreateAtTime, handleLongPressAt, handleTapAt, draft,
],
);
const getItemLayout = React.useCallback(
@@ -356,6 +388,21 @@ function TimeGridScrollViewInner({
),
};
}
// The remounted list starts at that day, but the header strip still holds
// the old pixel offset until the list scrolls: move it now, or the header
// shows other days (or none) after a rotation.
React.useLayoutEffect(() => {
const index = initialIndexRef.current?.index ?? 0;
scrolledSinceMountRef.current = false;
offsetXRef.current = index * colWidth;
scrollX.setValue(offsetXRef.current);
const day = days[index];
if (day) {
headerAnchorRef.current = day;
setHeaderAnchor(day);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listKey]);
const listStyle = React.useMemo(() => ({ width: listWidth, height: GRID_HEIGHT }), [listWidth]);
return (
@@ -365,7 +412,9 @@ function TimeGridScrollViewInner({
<View style={{ height: HEADER_HEIGHT }} />
{allDayHeight > 0 && (
<View style={[styles.allDayLabelWrap, { height: allDayHeight }]}>
<Text style={styles.allDayLabel} numberOfLines={2}>
{/* One line that shrinks to the gutter: a second line was cut
off by a one-row strip, leaving "All". */}
<Text style={styles.allDayLabel} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.6}>
{t('calendar.events.all_day', 'All day')}
</Text>
</View>
@@ -379,34 +428,26 @@ function TimeGridScrollViewInner({
]}
>
{headerCells}
{visibleSegments.map((segment) => {
const color = getEventColor(segment.event, calendars);
return (
<Pressable
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
onPress={() => onSelectEvent?.(segment.event)}
style={[
styles.allDayChip,
{
left: segment.startIndex * colWidth + 1,
width: segment.span * colWidth - 2,
top: HEADER_HEIGHT + 2 + segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
backgroundColor: color + '33',
borderLeftColor: color,
borderTopLeftRadius: segment.continuesBefore ? 0 : radius.xs,
borderBottomLeftRadius: segment.continuesBefore ? 0 : radius.xs,
borderTopRightRadius: segment.continuesAfter ? 0 : radius.xs,
borderBottomRightRadius: segment.continuesAfter ? 0 : radius.xs,
},
]}
>
<Text style={styles.allDayChipText} numberOfLines={1}>
{segment.continuesBefore ? '… ' : ''}
{segment.event.title || t('calendar.events.no_title', '(No title)')}
</Text>
</Pressable>
);
})}
{visibleSegments.map((segment) => (
<AllDayEventBar
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
title={segment.event.title || t('calendar.events.no_title', '(No title)')}
colors={eventBlockColors(
getEventColor(segment.event, calendars),
isInactiveEvent(segment.event, currentUserEmails),
c,
)}
continuesBefore={segment.continuesBefore}
continuesAfter={segment.continuesAfter}
onPress={() => onSelectEvent?.(segment.event)}
style={{
left: segment.startIndex * colWidth + 1,
width: segment.span * colWidth - 2,
top: HEADER_HEIGHT + 2 + segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
height: ALL_DAY_CHIP_HEIGHT,
}}
/>
))}
</Animated.View>
</View>
</View>
@@ -439,6 +480,7 @@ function TimeGridScrollViewInner({
data={days}
keyExtractor={dayKey}
renderItem={renderItem}
extraData={draft}
getItemLayout={getItemLayout}
initialScrollIndex={initialIndexRef.current.index}
// Days added at the start keep the columns on screen in place.
@@ -526,9 +568,13 @@ const DayColumn = React.memo(function DayColumn({
nowMinutes,
calendars,
timeFormat,
currentUserEmails,
colors,
styles,
onSelectEvent,
onLongPressAt,
onTapAt,
draftHour,
}: {
day: Date;
layouts: TimedEventLayout[];
@@ -537,9 +583,14 @@ const DayColumn = React.memo(function DayColumn({
nowMinutes: number;
calendars: Calendar[];
timeFormat: TimeFormat;
currentUserEmails?: string[];
colors: ThemePalette;
styles: GridStyles;
onSelectEvent?: (event: CalendarEvent) => void;
onLongPressAt?: (day: Date, hour: number) => void;
onTapAt?: (day: Date, hour: number) => void;
/** Hour of the "New event" block in this column, or -1. */
draftHour: number;
}) {
const { t } = useCalendarLocale();
const handleLongPress = React.useCallback(
@@ -548,43 +599,54 @@ const DayColumn = React.memo(function DayColumn({
},
[onLongPressAt, day],
);
const handlePress = React.useCallback(
(e: GestureResponderEvent) => {
onTapAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT));
},
[onTapAt, day],
);
return (
<Pressable
style={[styles.dayCol, { width }]}
onPress={onTapAt ? handlePress : undefined}
onLongPress={onLongPressAt ? handleLongPress : undefined}
>
{layouts.map(({ event, column, totalColumns, startMinutes, endMinutes }) => {
{layouts.map(({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => {
const top = (startMinutes / 60) * HOUR_HEIGHT;
const height = Math.max(20, ((endMinutes - startMinutes) / 60) * HOUR_HEIGHT - 1);
const widthPct = 100 / totalColumns;
const color = getEventColor(event, calendars);
return (
<Pressable
<TimedEventBlock
key={event.id}
onPress={() => onSelectEvent?.(event)}
style={[
styles.eventBlock,
{
top,
height,
left: `${column * widthPct}%`,
width: `${widthPct}%`,
backgroundColor: color + '33',
borderLeftColor: color,
},
]}
>
<Text style={styles.eventBlockTitle} numberOfLines={1}>
{event.title || t('calendar.events.no_title', '(No title)')}
</Text>
{height > 32 && (
<Text style={styles.eventBlockTime} numberOfLines={1}>
{minutesToTimeLabel(startMinutes, timeFormat)}
</Text>
title={event.title || t('calendar.events.no_title', '(No title)')}
timeLabel={endMinutes - startMinutes >= TIME_LINE_MIN_MINUTES
? minutesToTimeLabel(startMinutes, timeFormat)
: null}
colors={eventBlockColors(
getEventColor(event, calendars),
isInactiveEvent(event, currentUserEmails),
colors,
)}
</Pressable>
ringColor={colors.background}
continuesBefore={continuesBefore}
continuesAfter={continuesAfter}
onPress={() => onSelectEvent?.(event)}
style={{ top, height, left: `${column * widthPct}%`, width: `${widthPct}%` }}
/>
);
})}
{draftHour >= 0 && (
<Pressable
style={[styles.draft, { top: draftHour * HOUR_HEIGHT, height: HOUR_HEIGHT - 1 }]}
onPress={() => onLongPressAt?.(day, draftHour)}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_event', 'New event')}
>
<Text style={styles.draftText} numberOfLines={1}>
{t('calendar.events.new_event', 'New event')}
</Text>
</Pressable>
)}
{nowMinutes >= 0 && (
<View pointerEvents="none" style={[styles.nowLine, { top: (nowMinutes / 60) * HOUR_HEIGHT }]}>
<View style={styles.nowDot} />
@@ -649,14 +711,6 @@ function makeStyles(c: ThemePalette) {
allDayLabelWrap: { alignItems: 'flex-end', justifyContent: 'flex-start', paddingRight: 4, paddingTop: 2 },
allDayLabel: { ...typography.small, color: c.textMuted, textAlign: 'right' },
allDayChip: {
position: 'absolute',
height: ALL_DAY_CHIP_HEIGHT,
borderLeftWidth: 2,
paddingHorizontal: 4,
justifyContent: 'center',
},
allDayChipText: { fontSize: 10, color: c.text, fontWeight: '500' },
vScroll: { flex: 1 },
gridRow: { flexDirection: 'row', height: GRID_HEIGHT },
@@ -678,16 +732,6 @@ function makeStyles(c: ThemePalette) {
borderLeftWidth: 1,
borderLeftColor: c.border,
},
eventBlock: {
position: 'absolute',
borderRadius: radius.xs,
borderLeftWidth: 2,
paddingHorizontal: 3,
paddingVertical: 2,
overflow: 'hidden',
},
eventBlockTitle: { fontSize: 10, color: c.text, fontWeight: '600' },
eventBlockTime: { fontSize: 9, color: c.textMuted, marginTop: 1 },
nowLine: {
position: 'absolute',
left: -3,
@@ -702,5 +746,18 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.error,
},
nowBar: { flex: 1, height: 1, backgroundColor: c.error },
draft: {
position: 'absolute',
left: 1,
right: 2,
borderRadius: 2,
borderWidth: 1,
borderColor: c.primary,
backgroundColor: c.primaryBg,
paddingHorizontal: 4,
paddingVertical: 2,
zIndex: 2,
},
draftText: { fontSize: 12, lineHeight: 16, fontWeight: '500', color: c.text },
});
}
+41 -68
View File
@@ -9,7 +9,7 @@ import {
import { addDays, format, isSameDay, startOfWeek } from 'date-fns';
import { displayNow, displayNowMinutes, isDisplayToday } from '../../lib/calendar-timezone';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useCalendarLocale } from '../../lib/calendar-locale';
import {
@@ -24,6 +24,9 @@ import {
type CalendarWeekSegment,
type EventDayIndex,
} from '../../lib/calendar-utils';
import { isInactiveEvent } from '../../lib/calendar-participants';
import { eventBlockColors } from '../../lib/event-colors';
import { AllDayEventBar, TIME_LINE_MIN_MINUTES, TimedEventBlock } from './EventBlock';
const HOUR_HEIGHT = 48;
const GUTTER_WIDTH = 44;
@@ -43,6 +46,8 @@ interface WeekViewProps {
onCreateAtTime?: (date: Date) => void;
weekStartsOn?: 0 | 1 | 6;
timeFormat?: '12h' | '24h';
/** The user's addresses, to draw events they declined as inactive. */
currentUserEmails?: string[];
}
function WeekViewInner({
@@ -56,6 +61,7 @@ function WeekViewInner({
onCreateAtTime,
weekStartsOn = 0,
timeFormat = '24h',
currentUserEmails,
}: WeekViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -173,7 +179,7 @@ function WeekViewInner({
{allDayRowCount > 0 && (
<View style={[styles.allDayStrip, { height: allDayStripHeight }]}>
<View style={[styles.gutter, styles.allDayLabelWrap]}>
<Text style={styles.allDayLabel} numberOfLines={2}>{t('calendar.events.all_day', 'All day')}</Text>
<Text style={styles.allDayLabel} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.6}>{t('calendar.events.all_day', 'All day')}</Text>
</View>
<View style={styles.allDayGrid}>
{/* Empty per-day columns to draw vertical separators */}
@@ -183,35 +189,28 @@ function WeekViewInner({
{/* Segments overlay - multi-day events span their date range */}
<View style={styles.allDayOverlay} pointerEvents="box-none">
{allDaySegments.map((segment) => {
const color = getEventColor(segment.event, calendars);
const leftPct = (segment.startIndex / weekDays.length) * 100;
const widthPct = (segment.span / weekDays.length) * 100;
return (
<Pressable
<AllDayEventBar
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
title={segment.event.title || t('calendar.events.no_title', '(No title)')}
colors={eventBlockColors(
getEventColor(segment.event, calendars),
isInactiveEvent(segment.event, currentUserEmails),
c,
)}
continuesBefore={segment.continuesBefore}
continuesAfter={segment.continuesAfter}
onPress={() => onSelectEvent?.(segment.event)}
style={[
styles.allDayChip,
{
position: 'absolute',
left: `${leftPct}%`,
width: `${widthPct}%`,
top: segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
backgroundColor: color + '33',
borderLeftColor: color,
borderTopLeftRadius: segment.continuesBefore ? 0 : radius.xs,
borderBottomLeftRadius: segment.continuesBefore ? 0 : radius.xs,
borderTopRightRadius: segment.continuesAfter ? 0 : radius.xs,
borderBottomRightRadius: segment.continuesAfter ? 0 : radius.xs,
marginHorizontal: 1,
},
]}
>
<Text style={styles.allDayChipText} numberOfLines={1}>
{segment.continuesBefore ? '… ' : ''}
{segment.event.title || t('calendar.events.no_title', '(No title)')}
</Text>
</Pressable>
style={{
left: `${leftPct}%`,
width: `${widthPct}%`,
top: segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
height: ALL_DAY_CHIP_HEIGHT,
marginHorizontal: 1,
}}
/>
);
})}
</View>
@@ -255,7 +254,7 @@ function WeekViewInner({
))}
{layouted.map(
({ event, column, totalColumns, startMinutes, endMinutes }) => {
({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => {
const top = (startMinutes / 60) * HOUR_HEIGHT;
const height = Math.max(
20,
@@ -263,32 +262,24 @@ function WeekViewInner({
);
const widthPct = 100 / totalColumns;
const leftPct = column * widthPct;
const color = getEventColor(event, calendars);
return (
<Pressable
<TimedEventBlock
key={event.id}
onPress={() => onSelectEvent?.(event)}
style={[
styles.eventBlock,
{
top,
height,
left: `${leftPct}%`,
width: `${widthPct}%`,
backgroundColor: color + '33',
borderLeftColor: color,
},
]}
>
<Text style={styles.eventBlockTitle} numberOfLines={1}>
{event.title || t('calendar.events.no_title', '(No title)')}
</Text>
{height > 32 && (
<Text style={styles.eventBlockTime} numberOfLines={1}>
{minutesToTimeLabel(startMinutes, timeFormat)}
</Text>
title={event.title || t('calendar.events.no_title', '(No title)')}
timeLabel={endMinutes - startMinutes >= TIME_LINE_MIN_MINUTES
? minutesToTimeLabel(startMinutes, timeFormat)
: null}
colors={eventBlockColors(
getEventColor(event, calendars),
isInactiveEvent(event, currentUserEmails),
c,
)}
</Pressable>
ringColor={c.background}
continuesBefore={continuesBefore}
continuesAfter={continuesAfter}
onPress={() => onSelectEvent?.(event)}
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%` }}
/>
);
},
)}
@@ -389,14 +380,6 @@ function makeStyles(c: ThemePalette) {
top: 0,
bottom: 0,
},
allDayChip: {
height: ALL_DAY_CHIP_HEIGHT,
borderRadius: radius.xs,
borderLeftWidth: 2,
paddingHorizontal: 4,
justifyContent: 'center',
},
allDayChipText: { fontSize: 10, color: c.text, fontWeight: '500' },
scroll: { flex: 1 },
scrollContent: {},
@@ -419,16 +402,6 @@ function makeStyles(c: ThemePalette) {
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
eventBlock: {
position: 'absolute',
borderRadius: radius.xs,
borderLeftWidth: 2,
paddingHorizontal: 3,
paddingVertical: 2,
overflow: 'hidden',
},
eventBlockTitle: { fontSize: 10, color: c.text, fontWeight: '600' },
eventBlockTime: { fontSize: 9, color: c.textMuted, marginTop: 1 },
nowLine: {
position: 'absolute',
@@ -3,12 +3,14 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, Alert,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BookUser, X, Check, Plus } from 'lucide-react-native';
import { BookUser, X, Check, Plus } from '../icons';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore } from '../../stores/contacts-store';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
interface Props {
visible: boolean;
@@ -34,6 +36,8 @@ export default function AddressBookPickerSheet({
const [busy, setBusy] = React.useState(false);
const slideY = React.useRef(new Animated.Value(600)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
@@ -70,14 +74,14 @@ export default function AddressBookPickerSheet({
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -88,7 +92,7 @@ export default function AddressBookPickerSheet({
<Text style={styles.title}>{title ?? t('contacts.bulk.move_to_address_book', 'Move to address book')}</Text>
<Pressable
onPress={onClose}
hitSlop={8}
hitSlop={10}
style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
+4 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Mail as MailIcon, CalendarDays } from 'lucide-react-native';
import { Mail as MailIcon, CalendarDays } from '../icons';
import { format, isAfter, parseISO, type Locale } from 'date-fns';
import type { ContactCard, Email, CalendarEvent } from '../../api/types';
import type { RootStackParamList } from '../../navigation/types';
@@ -281,6 +281,9 @@ function makeStyles(c: ThemePalette) {
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
sectionIcon: { width: 16 },
sectionLabel: {
// Fill the row: shrink-wrapped, Android measured the letter-spaced
// label a few px short and wrapped its last word out of sight ("WOR").
flex: 1,
...typography.bodyMedium,
color: c.textSecondary,
textTransform: 'uppercase',
+10 -5
View File
@@ -1,10 +1,10 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, Alert, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import * as FileSystem from 'expo-file-system/legacy';
import { Upload, X, Check, FileText, AlertTriangle, BookUser } from 'lucide-react-native';
import { Upload, X, Check, FileText, AlertTriangle, BookUser } from '../icons';
import type { ContactCard } from '../../api/types';
import { parseVCard, detectDuplicates } from '../../lib/vcard';
import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils';
@@ -13,6 +13,9 @@ import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { SafeAreaModal } from '../SafeAreaModal';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen upload flow).
@@ -56,6 +59,8 @@ export default function ContactImportSheet({
const [result, setResult] = React.useState<{ imported: number; failed: number } | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const resetState = React.useCallback(() => {
@@ -160,11 +165,11 @@ export default function ContactImportSheet({
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>{t('contacts.import.title', 'Import Contacts')}</Text>
@@ -300,7 +305,7 @@ export default function ContactImportSheet({
)}
</SafeAreaView>
</Animated.View>
</Modal>
</SafeAreaModal>
);
}
+13 -2
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Check, Users } from 'lucide-react-native';
import { Check, Users } from '../icons';
import type { ContactCard } from '../../api/types';
import {
getContactDisplayName,
@@ -20,6 +20,8 @@ interface ContactListRowProps {
onPress?: () => void;
onLongPress?: () => void;
selected?: boolean;
/** The contact open in the detail pane of a split view. */
active?: boolean;
selectionMode?: boolean;
secondaryText?: string;
}
@@ -29,6 +31,7 @@ export default function ContactListRow({
onPress,
onLongPress,
selected,
active = false,
selectionMode,
secondaryText,
}: ContactListRowProps) {
@@ -62,6 +65,7 @@ export default function ContactListRow({
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
active && styles.rowActive,
selected && styles.rowSelected,
]}
>
@@ -99,6 +103,13 @@ function makeStyles(c: ThemePalette) {
},
rowPressed: { backgroundColor: c.surfaceHover },
rowSelected: { backgroundColor: c.primaryBg },
// Same as the open message in the mail list (webmail bg-selection + bar).
rowActive: {
backgroundColor: c.selection,
borderLeftWidth: 2,
borderLeftColor: c.primary,
paddingLeft: spacing.lg - 2,
},
checkbox: {
width: 22,
height: 22,
@@ -129,6 +140,6 @@ function makeStyles(c: ThemePalette) {
info: { flex: 1, minWidth: 0 },
name: { ...typography.bodyMedium, color: c.text },
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 1 },
tertiary: { ...typography.small, color: c.textSecondary, marginTop: 1 },
tertiary: { ...typography.caption, color: c.mutedForeground, marginTop: 1 },
});
}
+10 -5
View File
@@ -1,10 +1,10 @@
import React from 'react';
import {
View, Text, StyleSheet, Pressable, Modal, TextInput, FlatList,
View, Text, StyleSheet, Pressable, TextInput, FlatList,
Animated, Dimensions, Easing,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Search, X, Check } from 'lucide-react-native';
import { Search, X, Check } from '../icons';
import type { ContactCard } from '../../api/types';
import {
useContactsStore,
@@ -16,6 +16,9 @@ import { useSheetDrag } from '../../lib/use-sheet-drag';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import { SafeAreaModal } from '../SafeAreaModal';
interface Props {
visible: boolean;
@@ -41,6 +44,8 @@ export default function ContactPickerSheet({
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlay = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({
slideY,
@@ -94,12 +99,12 @@ export default function ContactPickerSheet({
};
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView style={{ flex: 1 }} edges={['bottom']}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
@@ -175,7 +180,7 @@ export default function ContactPickerSheet({
/>
</SafeAreaView>
</Animated.View>
</Modal>
</SafeAreaModal>
);
}
@@ -1,13 +1,13 @@
import React from 'react';
import {
View, Text, StyleSheet, Pressable, ScrollView, Modal, Animated, Dimensions, Easing, TextInput, Alert,
View, Text, StyleSheet, Pressable, ScrollView, Animated, Dimensions, Easing, TextInput, Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check,
} from 'lucide-react-native';
} from '../icons';
import type { RootStackParamList } from '../../navigation/types';
import type { AddressBook } from '../../api/types';
import type { ContactCategory } from '../../stores/contacts-store';
@@ -18,6 +18,8 @@ import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/co
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { SafeAreaModal } from '../SafeAreaModal';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -161,7 +163,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
);
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
@@ -309,7 +311,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
</ScrollView>
</SafeAreaView>
</Animated.View>
</Modal>
</SafeAreaModal>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Plus, X } from 'lucide-react-native';
import { Plus, X } from '../icons';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
+8 -4
View File
@@ -3,12 +3,14 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Tag, X, Plus } from 'lucide-react-native';
import { Tag, X, Plus } from '../icons';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore, selectKeywordsUsed } from '../../stores/contacts-store';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
interface Props {
visible: boolean;
@@ -35,6 +37,8 @@ export default function TagAssignSheet({
const [input, setInput] = React.useState('');
const slideY = React.useRef(new Animated.Value(600)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
@@ -60,14 +64,14 @@ export default function TagAssignSheet({
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -78,7 +82,7 @@ export default function TagAssignSheet({
<Text style={styles.title}>{title ?? t('contacts.bulk.add_tag', 'Add tag')}</Text>
<Pressable
onPress={onClose}
hitSlop={8}
hitSlop={10}
style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
+8 -4
View File
@@ -1,11 +1,13 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Modal, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X } from 'lucide-react-native';
import { X } from '../icons';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
export interface ActionSheetItem {
key: string;
@@ -37,6 +39,8 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children
const insets = useSafeAreaInsets();
const t = useLocaleStore((s) => s.t);
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -55,14 +59,14 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children
}, [visible, slideY, overlayOpacity]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -74,7 +78,7 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children
<Text style={styles.title} numberOfLines={1}>{title}</Text>
{subtitle ? <Text style={styles.subtitle} numberOfLines={1}>{subtitle}</Text> : null}
</View>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}
<Pressable onPress={onClose} hitSlop={10} style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
+1 -1
View File
@@ -3,7 +3,7 @@ import { Alert } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import * as Clipboard from 'expo-clipboard';
import { Copy, PenSquare, UserRound, UserRoundPlus } from 'lucide-react-native';
import { Copy, PenSquare, UserRound, UserRoundPlus } from '../icons';
import type { EmailAddress } from '../../api/types';
import type { RootStackParamList } from '../../navigation/types';
import { useContactsStore } from '../../stores/contacts-store';
+30 -97
View File
@@ -1,23 +1,22 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from 'lucide-react-native';
import type { File } from 'expo-file-system';
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from '../icons';
import type { Attachment, Email } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import {
shareAttachment, downloadAttachment, shareAttachmentViaSheet, fetchBlobBytes,
shareBytes, shareLocalFile, cachePreviewFile, writePreviewFile, discardPreviewFile,
shareAttachment, downloadAttachment, shareAttachmentViaSheet, fetchBlobBytes, shareBytes, attachmentSaveName,
} from '../../lib/email-export';
import {
getAttachmentDisplayName, visibleAttachments, previewKindFor, formatSize,
getAttachmentDisplayName, visibleAttachments, formatSize,
} from '../../lib/attachment-display';
import { emailExportFilename } from '../../lib/download-filename';
import { ActionSheet, type ActionSheetItem } from './ActionSheet';
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
import { emlPreviewFromBytes, type ExtractedAttachment } from './use-body-override';
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
import type { ExtractedAttachment } from './use-body-override';
import { previewKindOf, useFilePreview, type PreviewSource } from './use-file-preview';
interface Props {
email: Email;
@@ -65,24 +64,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
const [expanded, setExpanded] = React.useState(false);
const [busyKey, setBusyKey] = React.useState<string | null>(null);
const [sheetItem, setSheetItem] = React.useState<Item | null>(null);
const [preview, setPreview] = React.useState<PreviewItem | null>(null);
const [previewLoading, setPreviewLoading] = React.useState(false);
const previewSource = React.useRef<Item | null>(null);
// The file the open preview shows, in a temp folder of its own: images are
// cached by path, so two attachments both called "image001.png" must not
// share one. Deleted when the preview closes, unless it went to another
// app, which may still be reading it; the stale-export sweep gets that one.
const previewFile = React.useRef<{ file: File; handedOff: boolean } | null>(null);
// Bumped whenever a preview ends, so a download that finishes after its
// preview was closed is dropped instead of reopening it.
const previewSeq = React.useRef(0);
const endPreview = React.useCallback(() => {
previewSeq.current += 1;
const open = previewFile.current;
previewFile.current = null;
if (open && !open.handedOff) discardPreviewFile(open.file);
}, []);
React.useEffect(() => endPreview, [endPreview]);
const preview = useFilePreview();
const items = React.useMemo<Item[]>(() => {
const blobs = visibleAttachments(email, { hideInlineImageAttachments, calendarBannerShown, tnefUnpacked })
@@ -134,49 +116,29 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
else await shareBytes(item.part.bytes, item.name, item.type, { forceSheet: true });
}, t('email_viewer.attachment_failed', 'Could not share attachment'));
const showPreview = (item: Item) => {
const kind = previewKindFor({ name: item.name, type: item.type });
if (kind === 'none') { void open(item); return; }
previewSource.current = item;
endPreview();
const seq = previewSeq.current;
const stillOpen = () => seq === previewSeq.current;
setPreviewLoading(true);
setPreview(null);
void (async () => {
try {
if (kind === 'text' || kind === 'eml') {
const bytes = item.kind === 'blob'
? await fetchBlobBytes(item.att.blobId, item.att.name, item.att.type, jmapAccountId)
: item.part.bytes;
const next: PreviewItem = kind === 'eml'
? { kind, name: item.name, mimeType: item.type, eml: await emlPreviewFromBytes(bytes) }
: { kind, name: item.name, mimeType: item.type, text: new TextDecoder('utf-8').decode(bytes) };
if (stillOpen()) setPreview(next);
return;
}
const file = item.kind === 'blob'
? await cachePreviewFile(item.att.blobId, item.name, item.type, jmapAccountId)
: writePreviewFile(item.part.bytes, item.name, item.type);
if (!stillOpen()) {
discardPreviewFile(file);
return;
}
previewFile.current = { file, handedOff: false };
setPreview({ kind, name: item.name, mimeType: item.type, fileUri: file.uri });
} catch (e) {
if (!stillOpen()) return;
setPreview(null);
Alert.alert(t('email_viewer.attachment_failed', 'Could not open attachment'), e instanceof Error ? e.message : String(e));
} finally {
if (stillOpen()) setPreviewLoading(false);
}
})();
};
const sourceOf = (item: Item): PreviewSource => (item.kind === 'blob'
? {
from: 'blob',
blobId: item.att.blobId,
accountId: jmapAccountId,
email,
name: item.name,
type: item.type,
size: item.size,
saveName: attachmentSaveName(email, item.att.name, item.att.type),
}
: { from: 'bytes', bytes: item.part.bytes, name: item.name, type: item.type, size: item.size });
// The in-app preview where the type has one, else straight to another app.
const showPreview = (item: Item) => run(
item.key,
() => preview.open(sourceOf(item)),
t('email_viewer.attachment_failed', 'Could not open attachment'),
);
const onTap = (item: Item) => {
if (mailAttachmentAction === 'download') void save(item);
else showPreview(item);
else void showPreview(item);
};
const downloadAll = () => run('__all__', async () => {
@@ -204,11 +166,11 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
const visible = expanded ? items : items.slice(0, 3);
const sheetActions: ActionSheetItem[] = sheetItem ? [
...(previewKindFor({ name: sheetItem.name, type: sheetItem.type }) !== 'none' ? [{
...(previewKindOf(sheetItem) !== 'none' ? [{
key: 'preview',
label: t('email_viewer.attachment_actions.preview', 'Preview'),
icon: <Eye size={18} color={c.textSecondary} />,
onPress: () => { const it = sheetItem; setSheetItem(null); showPreview(it); },
onPress: () => { const it = sheetItem; setSheetItem(null); void showPreview(it); },
}] : []),
{
key: 'open',
@@ -278,36 +240,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
onClose={() => setSheetItem(null)}
/>
<AttachmentPreviewModal
item={preview}
loading={previewLoading}
onClose={() => {
endPreview();
setPreview(null);
setPreviewLoading(false);
}}
onOpenExternal={() => {
const it = previewSource.current;
if (!it) return;
if (preview?.fileUri && it.kind === 'blob') {
void run(it.key, () => shareAttachment(it.att.blobId, it.att.name, it.att.type, email, jmapAccountId), t('email_viewer.attachment_failed', 'Could not open attachment'));
} else {
void open(it);
}
}}
onShare={() => {
const it = previewSource.current;
if (!it) return;
const open = previewFile.current;
if (open) {
// The receiving app may keep reading it after the sheet closes.
open.handedOff = true;
void shareLocalFile(open.file, it.type, it.name, { forceSheet: true, keep: true }).catch(() => undefined);
} else {
void share(it);
}
}}
/>
<AttachmentPreviewModal {...preview.modalProps} />
</View>
);
}
+271 -43
View File
@@ -1,28 +1,45 @@
import React from 'react';
import {
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, useWindowDimensions,
View, Text, StyleSheet, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
useWindowDimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { WebView } from 'react-native-webview';
import { X, ExternalLink, Share2, Download } from 'lucide-react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { X, ExternalLink, Share2, Download } from '../icons';
import type { Email } from '../../api/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useSettingsStore } from '../../stores/settings-store';
import EmailBodyView from '../EmailBodyView';
import type { PreviewKind } from '../../lib/attachment-display';
import {
handoffMimeType, needsNativeImageDecoder, needsWebImageViewer, type PreviewKind,
} from '../../lib/file-preview';
import { pdfPageFileName, renderPdfPage } from '../../lib/pdf-pages';
import type { ZipArchive } from '../../lib/zip-preview';
import { formatFullDateTime } from '../../lib/email-date';
import type { EmlPreview } from './use-body-override';
import { useFilePreview } from './use-file-preview';
import { ArchiveSummary, PreviewPartList, type PreviewPart } from './PreviewPartList';
import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
export interface PreviewItem {
kind: PreviewKind;
name: string;
mimeType: string;
/** Local file:// URI for images and PDFs. */
/** Local file:// URI of the downloaded copy (images, PDFs, media). */
fileUri?: string;
/** The viewer page next to the file (images, video, audio, Android's PDFs); see preview-viewer-html. */
viewerUri?: string;
/** Decoded text for text-like parts. */
text?: string;
/** The document for HTML and Markdown files, shown through the mail sanitiser. */
html?: string;
/** Parsed embedded message for .eml previews. */
eml?: EmlPreview;
/** A ZIP's directory, for archive previews. */
archive?: ZipArchive;
/** Replaces "No preview for this file type." on the fallback screen (e.g. the file is too large). */
notice?: string;
}
@@ -33,48 +50,191 @@ interface Props {
/** Header title while the item is still loading. */
title?: string;
onClose: () => void;
/** Hand the file to an external app (PDF on Android, unsupported types, the header's Open button). */
/** Hand the file to another app (types without a preview, the header's Open button). */
onOpenExternal: () => void;
onShare: () => void;
/** Save a copy; adds a Download button to the header. */
onDownload?: () => void;
/** Render in place (a split view's side pane) instead of a full-screen modal. */
docked?: boolean;
}
interface ViewerWebViewProps {
uri: string;
background: string;
onFailed: () => void;
/** Messages other than 'error'. */
onMessage?: (data: string) => void;
webRef?: React.Ref<WebView>;
}
/**
* In-app preview for images, PDFs (iOS renders them in the WebView; Android
* has no built-in PDF renderer, so it offers the external viewer), text and
* embedded messages. Anything else goes to the external viewer / share sheet.
* Used by the reader's attachment chips and the Files tab.
* A local page in a WebView that may read only its own folder: the image,
* media and PDF viewers. It posts 'error' when the file doesn't decode.
*/
function ViewerWebView({ uri, background, onFailed, onMessage, webRef }: ViewerWebViewProps) {
const folder = uri.slice(0, uri.lastIndexOf('/') + 1);
const handleMessage = (e: WebViewMessageEvent) => {
if (e.nativeEvent.data === 'error') onFailed();
else onMessage?.(e.nativeEvent.data);
};
return (
<WebView
ref={webRef}
source={{ uri }}
originWhitelist={['file://*']}
allowFileAccess
allowingReadAccessToURL={folder}
javaScriptEnabled
domStorageEnabled={false}
cacheEnabled={false}
incognito
allowsInlineMediaPlayback
mediaPlaybackRequiresUserAction={false}
allowsFullscreenVideo
allowsPictureInPictureMediaPlayback
allowsAirPlayForMediaPlayback
setSupportMultipleWindows={false}
setBuiltInZoomControls
setDisplayZoomControls={false}
overScrollMode="never"
bounces={false}
onShouldStartLoadWithRequest={(req) => req.url === uri}
onMessage={handleMessage}
onError={onFailed}
style={[viewerStyles.fill, { backgroundColor: background }]}
containerStyle={{ backgroundColor: background }}
/>
);
}
// Pages are rendered half again as wide as the screen's pixels, so text stays
// sharp at the first zoom step; PdfRenderer's bitmap is capped natively.
const PDF_PAGE_OVERSAMPLE = 1.5;
/**
* Android's PDF view: the viewer page asks for each page as it comes near
* the screen, and the page is rendered next to it by the platform's
* PdfRenderer (pdf-pages.ts).
*/
function PdfPagesView({ fileUri, viewerUri, background, onFailed }: {
fileUri: string;
viewerUri: string;
background: string;
onFailed: () => void;
}) {
const ref = React.useRef<WebView>(null);
const { width } = useWindowDimensions();
const pagePx = PixelRatio.getPixelSizeForLayoutSize(Math.min(width, 1000)) * PDF_PAGE_OVERSAMPLE;
const folder = viewerUri.slice(0, viewerUri.lastIndexOf('/') + 1);
const onMessage = (data: string) => {
const m = /^page:(\d+)$/.exec(data);
if (!m) return;
const index = Number(m[1]);
const name = pdfPageFileName(index);
renderPdfPage(fileUri, index, pagePx, folder + name)
.then(() => ref.current?.injectJavaScript(`window.__pdfPage(${index}, ${JSON.stringify(name)});true;`))
.catch(() => undefined);
};
return <ViewerWebView webRef={ref} uri={viewerUri} background={background} onFailed={onFailed} onMessage={onMessage} />;
}
/**
* In-app preview for images, PDFs (iOS's WebView shows them, Android renders the pages), video
* and audio (the WebView's player), text, Markdown and HTML documents,
* embedded messages and ZIP archives. The files inside an archive or a
* message open in a preview of their own on top. Files that don't decode,
* and types without a preview, offer another app instead. Used by every
* place that opens a file.
*/
export function AttachmentPreviewModal({
item, loading, title, onClose, onOpenExternal, onShare, onDownload,
item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false,
}: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const { width } = useWindowDimensions();
// An image the platform can't decode would stay blank; fall back to the
// external viewer instead. Keyed by URI so the next item starts clean.
const [failedImageUri, setFailedImageUri] = React.useState<string | null>(null);
// Files inside this one (archive entries, an .eml's attachments).
const nested = useFilePreview();
const { open: openNested } = nested;
const openPart = React.useCallback(async (part: PreviewPart) => {
try {
const bytes = await part.read();
await openNested({
from: 'bytes',
bytes,
name: part.name,
type: part.type || handoffMimeType(part.name, ''),
size: bytes.byteLength,
});
} catch (e) {
Alert.alert(t('email_viewer.attachment_failed', 'Could not open attachment'), e instanceof Error ? e.message : String(e));
}
}, [openNested, t]);
const archive = item?.kind === 'archive' ? item.archive : undefined;
const archiveParts = React.useMemo<PreviewPart[]>(() => {
if (!archive) return [];
return archive.entries.map((entry) => ({
key: entry.path,
name: entry.name,
folder: entry.folder,
size: entry.size,
read: () => archive.read(entry.path),
}));
}, [archive]);
const emlAttachments = item?.kind === 'eml' ? item.eml?.attachments : undefined;
const emlParts = React.useMemo<PreviewPart[]>(() => (emlAttachments ?? []).map((att, i) => ({
key: `${i}-${att.name}`,
name: att.name,
type: att.type,
size: att.size,
read: async () => att.bytes,
})), [emlAttachments]);
// Which viewers failed for the open file, keyed by its URI so the next one
// starts clean: React Native's Image falls back to the WebView viewer, and
// a WebView that can't decode the file falls back to another app.
const [failed, setFailed] = React.useState<{ uri: string; native?: boolean; web?: boolean } | null>(null);
const markFailed = React.useCallback((uri: string | undefined, which: 'native' | 'web') => {
if (!uri) return;
setFailed((prev) => ({ ...(prev?.uri === uri ? prev : {}), uri, [which]: true }));
}, []);
// The image fits the area it is shown in (a pane, a landscape window),
// not a box derived from the window width that ran past the screen.
const [imageArea, setImageArea] = React.useState<{ width: number; height: number } | null>(null);
const emlEmail = React.useMemo<Email | null>(() => {
if (!item?.eml) return null;
const docEmail = React.useMemo<Email | null>(() => {
if (!item || (!item.eml && item.html == null)) return null;
return {
id: `eml-preview-${item.name}`,
id: `file-preview-${item.kind}-${item.fileUri ?? item.name}`,
threadId: '',
mailboxIds: {},
keywords: {},
size: 0,
receivedAt: item.eml.date ?? new Date().toISOString(),
receivedAt: item.eml?.date ?? new Date().toISOString(),
hasAttachment: false,
subject: item.eml.subject,
subject: item.eml?.subject ?? item.name,
};
}, [item]);
const imageFailed = item?.kind === 'image' && !!item.fileUri && failedImageUri === item.fileUri;
const pdfInline = item?.kind === 'pdf' && Platform.OS === 'ios' && !!item.fileUri;
const failure = item?.fileUri && failed?.uri === item.fileUri ? failed : null;
// Android's WebView zooms; React Native's Image there doesn't. iOS zooms
// the native image in its ScrollView. Each side's gaps go to the other.
const imageInWeb = item?.kind === 'image' && !!item.viewerUri && !failure?.web && (
failure?.native
|| (Platform.OS === 'android' ? !needsNativeImageDecoder(item) : needsWebImageViewer(item))
);
const imageFailed = item?.kind === 'image' && (
!!failure?.web && (!!failure.native || !item.viewerUri || needsWebImageViewer(item))
|| (!!failure?.native && !item.viewerUri)
);
const mediaFailed = (item?.kind === 'video' || item?.kind === 'audio') && !!failure?.web;
const pdfInline = item?.kind === 'pdf' && !!item.fileUri && (Platform.OS === 'ios' || !!item.viewerUri) && !failure?.web;
// The fallback screen has its own Open button; the header adds one elsewhere.
const showsFallback = !!item && (item.kind === 'none' || (item.kind === 'pdf' && !pdfInline) || imageFailed);
const showsFallback = !!item && (
item.kind === 'none' || (item.kind === 'pdf' && !pdfInline) || imageFailed || mediaFailed
);
const renderFallback = (message: string) => (
<View style={styles.centered}>
@@ -86,6 +246,14 @@ export function AttachmentPreviewModal({
</View>
);
const renderDocument = (header?: React.ReactNode, footer?: React.ReactNode) => (
<ScrollView style={styles.flex}>
{header}
{docEmail && <EmailBodyView email={docEmail} bodyOverride={{ html: item?.html ?? item?.eml?.html, text: item?.eml?.text }} />}
{footer}
</ScrollView>
);
const renderBody = () => {
if (loading || !item) {
return (
@@ -99,24 +267,64 @@ export function AttachmentPreviewModal({
if (imageFailed) {
return renderFallback(t('email_viewer.preview.unsupported', 'No preview for this file type.'));
}
if (imageInWeb && item.viewerUri) {
return (
<ViewerWebView
key={item.viewerUri}
uri={item.viewerUri}
background={c.background}
onFailed={() => markFailed(item.fileUri, 'web')}
/>
);
}
return (
<ScrollView
style={styles.flex}
contentContainerStyle={styles.imageScroll}
onLayout={(e) => {
const { width: w, height: h } = e.nativeEvent.layout;
setImageArea((prev) => (prev && prev.width === w && prev.height === h ? prev : { width: w, height: h }));
}}
maximumZoomScale={4}
minimumZoomScale={1}
bouncesZoom
>
<Image
source={{ uri: item.fileUri }}
style={{ width: width - spacing.lg * 2, height: width * 1.2 }}
style={imageArea
? { width: imageArea.width - spacing.lg * 2, height: imageArea.height - spacing.lg * 2 }
: { width: width - spacing.lg * 2, height: width * 1.2 }}
resizeMode="contain"
accessibilityLabel={item.name}
onError={() => setFailedImageUri(item.fileUri ?? null)}
onError={() => markFailed(item.fileUri, 'native')}
/>
</ScrollView>
);
case 'video':
case 'audio':
if (mediaFailed || !item.viewerUri) {
return renderFallback(t('email_viewer.preview.media_unsupported', "This file can't be played here."));
}
return (
<ViewerWebView
key={item.viewerUri}
uri={item.viewerUri}
background={item.kind === 'video' ? '#000' : c.background}
onFailed={() => markFailed(item.fileUri, 'web')}
/>
);
case 'pdf':
if (pdfInline && item.fileUri && item.viewerUri) {
return (
<PdfPagesView
key={item.viewerUri}
fileUri={item.fileUri}
viewerUri={item.viewerUri}
background={c.surface}
onFailed={() => markFailed(item.fileUri, 'web')}
/>
);
}
if (pdfInline && item.fileUri) {
return (
<WebView
@@ -131,37 +339,49 @@ export function AttachmentPreviewModal({
/>
);
}
return renderFallback(t('email_viewer.preview.pdf_external', 'PDFs open in your PDF viewer on this device.'));
return renderFallback(t('email_viewer.preview.pdf_unreadable', "This PDF can't be shown here."));
case 'text':
return (
<ScrollView style={styles.flex} contentContainerStyle={styles.textScroll}>
<Text selectable style={styles.text}>{item.text ?? ''}</Text>
</ScrollView>
);
case 'html':
case 'markdown':
return renderDocument();
case 'eml':
return renderDocument(
<View style={styles.emlHeader}>
<Text style={styles.emlSubject}>{item.eml?.subject || t('email_viewer.no_subject', '(No Subject)')}</Text>
{item.eml?.from ? <Text style={styles.emlMeta}>{item.eml.from}</Text> : null}
{item.eml?.date ? <Text style={styles.emlMeta}>{formatFullDateTime(item.eml.date, timeFormat, locale) || item.eml.date}</Text> : null}
</View>,
emlParts.length > 0 ? (
<PreviewPartList
inline
parts={emlParts}
onOpen={openPart}
header={<ArchiveSummary count={emlParts.length} truncated={false} />}
/>
) : null,
);
case 'archive':
if (!item.archive) break;
return (
<ScrollView style={styles.flex}>
<View style={styles.emlHeader}>
<Text style={styles.emlSubject}>{item.eml?.subject || t('email_viewer.no_subject', '(No Subject)')}</Text>
{item.eml?.from ? <Text style={styles.emlMeta}>{item.eml.from}</Text> : null}
{item.eml?.date ? <Text style={styles.emlMeta}>{item.eml.date}</Text> : null}
</View>
{emlEmail && (
<EmailBodyView
email={emlEmail}
bodyOverride={{ html: item.eml?.html, text: item.eml?.text }}
/>
)}
</ScrollView>
<PreviewPartList
parts={archiveParts}
onOpen={openPart}
header={<ArchiveSummary count={archiveParts.length} truncated={item.archive.truncated} />}
/>
);
default:
return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.'));
break;
}
return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.'));
};
return (
<Modal visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
const content = (
<SafeAreaView style={styles.container} edges={docked ? ['top', 'right'] : ['top', 'bottom', 'left', 'right']}>
<View style={styles.header}>
<Pressable
onPress={onClose}
@@ -208,11 +428,19 @@ export function AttachmentPreviewModal({
</Pressable>
</View>
{renderBody()}
{nested.modalProps.item || nested.modalProps.loading ? <AttachmentPreviewModal {...nested.modalProps} /> : null}
</SafeAreaView>
</Modal>
);
if (docked) return item || loading ? content : null;
return (
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
{content}
</SafeAreaModal>
);
}
const viewerStyles = StyleSheet.create({ fill: { flex: 1 } });
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
@@ -4,7 +4,7 @@ import { openExternalUrl } from '../../lib/open-url';
import {
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
ShieldCheck, ShieldAlert, ChevronDown,
} from 'lucide-react-native';
} from '../icons';
import { format, parseISO } from 'date-fns';
import type { Calendar, Email, CalendarEvent } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
+22 -63
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from 'lucide-react-native';
import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from '../icons';
import type { Attachment, Email, Mailbox } from '../../api/types';
import { getEmailAttachments } from '../../api/email';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -15,9 +15,10 @@ import {
attachmentKind, realAttachments, requestListAttachments, shortAttachmentName,
type AttachmentKind, type LoadListAttachments,
} from '../../lib/list-attachments';
import { getAttachmentDisplayName, previewKindFor } from '../../lib/attachment-display';
import { cacheBlobFile, downloadAttachment, fetchBlobBytes, shareAttachment, shareLocalFile } from '../../lib/email-export';
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
import { getAttachmentDisplayName } from '../../lib/attachment-display';
import { attachmentSaveName, downloadAttachment } from '../../lib/email-export';
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
import { useFilePreview } from './use-file-preview';
/** How many chips a row shows before collapsing the rest into a count. */
const MAX_CHIPS = 2;
@@ -124,26 +125,16 @@ export interface ListAttachmentOpenerHandle {
open: (email: Email, attachment: Attachment, accountId?: string) => void;
}
interface OpenTarget {
email: Email;
attachment: Attachment;
accountId?: string;
name: string;
type: string;
}
/**
* Opens a list-row attachment the way the reader's chips do: the "attachment
* click action" setting either saves it or previews it in-app (images, PDFs,
* text), and anything that can't be previewed goes to the platform viewer.
* Holds the preview modal, so the list itself never re-renders for it.
* click action" setting either saves it or previews it in-app, and anything
* that can't be previewed goes to another app. Holds the preview modal, so
* the list itself never re-renders for it.
*/
export const ListAttachmentOpener = React.forwardRef<ListAttachmentOpenerHandle>(
function ListAttachmentOpener(_props, ref) {
const t = useLocaleStore((s) => s.t);
const [preview, setPreview] = React.useState<PreviewItem | null>(null);
const [loading, setLoading] = React.useState(false);
const target = React.useRef<OpenTarget | null>(null);
const preview = useFilePreview();
const busy = React.useRef(false);
const run = React.useCallback(async (fn: () => Promise<void>) => {
@@ -161,59 +152,27 @@ export const ListAttachmentOpener = React.forwardRef<ListAttachmentOpenerHandle>
}
}, [t]);
const openExternal = React.useCallback((it: OpenTarget) => run(
() => shareAttachment(it.attachment.blobId, it.attachment.name, it.attachment.type, it.email, it.accountId),
), [run]);
const { open: openPreview } = preview;
React.useImperativeHandle(ref, () => ({
open: (email, attachment, accountId) => {
if (busy.current) return;
const name = getAttachmentDisplayName(attachment.name, attachment.type);
const type = attachment.type || 'application/octet-stream';
const it: OpenTarget = { email, attachment, accountId, name, type };
target.current = it;
if (useSettingsStore.getState().mailAttachmentAction === 'download') {
void run(() => downloadAttachment(attachment.blobId, attachment.name, attachment.type, email, accountId));
return;
}
const kind = previewKindFor({ name, type });
if (kind !== 'image' && kind !== 'pdf' && kind !== 'text') {
void openExternal(it);
return;
}
setPreview(null);
setLoading(true);
void run(async () => {
try {
if (kind === 'text') {
const bytes = await fetchBlobBytes(attachment.blobId, attachment.name, attachment.type, accountId);
setPreview({ kind, name, mimeType: type, text: new TextDecoder('utf-8').decode(bytes) });
} else {
const file = await cacheBlobFile(attachment.blobId, name, type, accountId);
setPreview({ kind, name, mimeType: type, fileUri: file.uri });
}
} finally {
setLoading(false);
}
});
void run(() => openPreview({
from: 'blob',
blobId: attachment.blobId,
accountId,
email,
name: getAttachmentDisplayName(attachment.name, attachment.type),
type: attachment.type || 'application/octet-stream',
size: attachment.size,
saveName: attachmentSaveName(email, attachment.name, attachment.type),
}));
},
}), [run, openExternal]);
}), [run, openPreview]);
return (
<AttachmentPreviewModal
item={preview}
loading={loading}
onClose={() => { setPreview(null); setLoading(false); }}
onOpenExternal={() => { if (target.current) void openExternal(target.current); }}
onShare={() => {
const it = target.current;
if (!it || !preview?.fileUri) return;
const { File } = require('expo-file-system') as typeof import('expo-file-system');
void shareLocalFile(new File(preview.fileUri), it.type, it.name, { forceSheet: true }).catch(() => undefined);
}}
/>
);
return <AttachmentPreviewModal {...preview.modalProps} />;
},
);
+4 -6
View File
@@ -2,14 +2,14 @@ import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import {
Star, ChevronDown, ChevronUp, Reply, Forward, ShieldCheck, ShieldAlert, ShieldQuestion, Lock,
} from 'lucide-react-native';
} from '../icons';
import type { Email, EmailAddress, Identity } from '../../api/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import SenderAvatar from '../SenderAvatar';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useKeywordsStore, keywordToken } from '../../stores/keywords-store';
import { useKeywordsStore } from '../../stores/keywords-store';
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
import {
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
@@ -17,6 +17,7 @@ import {
} from '../../lib/email-headers';
import { formatSize } from '../../lib/attachment-display';
import { isSmimeEmail } from '../../lib/smime';
import { resolveEmailTags } from '../../lib/thread-utils';
interface Props {
email: Email;
@@ -121,10 +122,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
return null;
}, [spoofed, identities, from?.email, email]);
const tags = React.useMemo(
() => keywordDefs.filter((kw) => !!email.keywords?.[keywordToken(kw.id)]),
[keywordDefs, email.keywords],
);
const tags = React.useMemo(() => resolveEmailTags(email.keywords, keywordDefs), [keywordDefs, email.keywords]);
const important = !!email.keywords?.$important;
const answered = !!email.keywords?.$answered;
const forwarded = !!email.keywords?.$forwarded;
+152
View File
@@ -0,0 +1,152 @@
import React from 'react';
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import {
File as FileIcon, FileArchive, FileAudio, FileImage, FileSpreadsheet, FileText, FileVideo,
} from '../icons';
import { formatSize } from '../../lib/attachment-display';
import { filePreviewKind, handoffMimeType } from '../../lib/file-preview';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
/** A file inside a file: an archive entry or an attachment of an .eml. */
export interface PreviewPart {
key: string;
name: string;
/** Folder inside an archive, '' at the top. */
folder?: string;
type?: string;
size?: number;
read: () => Promise<Uint8Array>;
}
function iconFor(part: PreviewPart) {
const type = part.type || handoffMimeType(part.name, '');
if (/\.(xlsx?|ods|csv|tsv|numbers)$/i.test(part.name)) return FileSpreadsheet;
if (/\.(zip|7z|rar|gz|tgz|tar|bz2|xz)$/i.test(part.name)) return FileArchive;
// Platform-independent: the icon names the format, not what plays here.
switch (filePreviewKind({ name: part.name, type }, { platform: 'android' })) {
case 'image': return FileImage;
case 'video': return FileVideo;
case 'audio': return FileAudio;
case 'none': return /^(application\/(msword|vnd\.|rtf)|text\/)/.test(type) ? FileText : FileIcon;
default: return FileText;
}
}
interface Props {
parts: PreviewPart[];
/** Opens a part; the row shows a spinner until the promise settles. */
onOpen: (part: PreviewPart) => Promise<void>;
/** Above the rows (an .eml's body, an archive's summary). */
header?: React.ReactElement;
/** Renders as a plain column inside a parent ScrollView instead of a list of its own. */
inline?: boolean;
}
/**
* The files inside an archive or an embedded message, each opening in the
* file preview like any attachment.
*/
export function PreviewPartList({ parts, onOpen, header, inline = false }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [busy, setBusy] = React.useState<string | null>(null);
const open = (part: PreviewPart) => {
if (busy) return;
setBusy(part.key);
onOpen(part).finally(() => setBusy(null));
};
const renderRow = (part: PreviewPart) => {
const Icon = iconFor(part);
return (
<Pressable
key={part.key}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => open(part)}
disabled={!!busy}
accessibilityRole="button"
accessibilityLabel={part.name}
>
<View style={styles.icon}>
{busy === part.key ? <ActivityIndicator size="small" color={c.textMuted} /> : <Icon size={20} color={c.textMuted} />}
</View>
<View style={styles.text}>
<Text style={styles.name} numberOfLines={1}>{part.name}</Text>
{part.folder ? <Text style={styles.folder} numberOfLines={1}>{part.folder}</Text> : null}
</View>
{part.size != null ? <Text style={styles.size}>{formatSize(part.size) || '0 B'}</Text> : null}
</Pressable>
);
};
if (inline) {
return (
<View style={styles.inline}>
{header}
{parts.map(renderRow)}
</View>
);
}
return (
<FlatList
style={styles.list}
data={parts}
keyExtractor={(p) => p.key}
renderItem={({ item }) => renderRow(item)}
ListHeaderComponent={header}
initialNumToRender={30}
/>
);
}
/** The summary line above an archive's entries. */
export function ArchiveSummary({ count, truncated }: { count: number; truncated: boolean }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return (
<Text style={styles.summary}>
{truncated
? t('email_viewer.preview.archive_first_files', 'The first {count} files', { count })
: t('email_viewer.preview.archive_files', '{count, plural, one {# file} other {# files}}', { count })}
</Text>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
list: { flex: 1 },
inline: { paddingBottom: spacing.lg },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
minHeight: 52,
},
rowPressed: { backgroundColor: c.surfaceHover },
icon: {
width: 36,
height: 36,
borderRadius: radius.md,
backgroundColor: c.surface,
alignItems: 'center',
justifyContent: 'center',
},
text: { flex: 1, minWidth: 0 },
name: { ...typography.body, color: c.text },
folder: { ...typography.caption, color: c.textMuted },
size: { ...typography.caption, color: c.textMuted },
summary: {
...typography.caption,
color: c.textMuted,
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
});
}
+18 -8
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert, Keyboard } from 'react-native';
import { Send, Maximize2 } from 'lucide-react-native';
import { Send, Maximize2 } from '../icons';
import type { Email } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -31,6 +31,8 @@ interface Props {
onMoreOptions: (draft: string) => void;
/** Reflect `$answered` in the caller's cache. */
onSent?: (email: Email) => void;
onFocus?: () => void;
onBlur?: () => void;
}
/**
@@ -38,7 +40,7 @@ interface Props {
* to the sender with the original quoted, sent through the identity that
* received the message. "More options" hands the text to the full composer.
*/
export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: Props) {
export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onFocus, onBlur }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -154,16 +156,21 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
return (
<View style={styles.box}>
<TextInput
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
value={text}
onChangeText={setText}
onFocus={onFocus}
onBlur={onBlur}
placeholder={t('email_viewer.quick_reply_placeholder', 'Write a quick reply...')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
style={styles.input}
multiline
editable={!sending}
/>
<View style={styles.actions}>
<Pressable style={styles.more} onPress={() => onMoreOptions(text)} hitSlop={6}>
<Pressable style={styles.more} onPress={() => onMoreOptions(text)} hitSlop={10} accessibilityRole="button">
<Maximize2 size={14} color={c.textSecondary} />
<Text style={styles.moreText}>{t('email_viewer.more_options', 'More options')}</Text>
</Pressable>
@@ -171,6 +178,9 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
style={[styles.send, (!text.trim() || sending) && styles.sendDisabled]}
onPress={send}
disabled={!text.trim() || sending}
hitSlop={10}
accessibilityRole="button"
accessibilityState={{ disabled: !text.trim() || sending, busy: sending }}
>
{sending ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Send size={14} color={c.primaryForeground} />}
<Text style={styles.sendText}>{sending ? t('email_viewer.sending', 'Sending...') : t('email_viewer.send', 'Send')}</Text>
@@ -188,7 +198,7 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
backgroundColor: c.surface,
backgroundColor: c.background,
padding: spacing.sm,
gap: spacing.sm,
},
@@ -210,10 +220,10 @@ function makeStyles(c: ThemePalette) {
gap: spacing.xs,
backgroundColor: c.primary,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.full,
minHeight: 36,
borderRadius: radius.sm,
},
sendDisabled: { opacity: 0.5 },
sendText: { ...typography.caption, color: c.primaryForeground, fontWeight: '600' },
sendText: { ...typography.bodyMedium, color: c.primaryForeground },
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
import { MailCheck } from 'lucide-react-native';
import { MailCheck } from '../icons';
import type { Email } from '../../api/types';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
+12 -11
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Paperclip, Reply, ReplyAll, Forward, Star } from 'lucide-react-native';
import { Paperclip, Reply, ReplyAll, Forward, Star } from '../icons';
import type { Email } from '../../api/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -74,16 +74,16 @@ export function ThreadMessageCard({
<MessageContent {...content} compact={false} onToggleStar={bare ? undefined : onToggleStar} />
{!bare && (
<View style={[styles.actions, !replyable && styles.actionsDisabled]}>
<Pressable style={styles.actionBtn} onPress={reply('reply')} hitSlop={4}>
<Reply size={16} color={c.textSecondary} />
<Pressable style={styles.actionBtn} onPress={reply('reply')} hitSlop={6}>
<Reply size={16} color={c.text} />
<Text style={styles.actionLabel}>{t('email_viewer.reply', 'Reply')}</Text>
</Pressable>
<Pressable style={styles.actionBtn} onPress={reply('replyAll')} hitSlop={4}>
<ReplyAll size={16} color={c.textSecondary} />
<Pressable style={styles.actionBtn} onPress={reply('replyAll')} hitSlop={6}>
<ReplyAll size={16} color={c.text} />
<Text style={styles.actionLabel}>{t('email_viewer.reply_all', 'Reply All')}</Text>
</Pressable>
<Pressable style={styles.actionBtn} onPress={reply('forward')} hitSlop={4}>
<Forward size={16} color={c.textSecondary} />
<Pressable style={styles.actionBtn} onPress={reply('forward')} hitSlop={6}>
<Forward size={16} color={c.text} />
<Text style={styles.actionLabel}>{t('email_viewer.forward', 'Forward')}</Text>
</Pressable>
</View>
@@ -159,13 +159,14 @@ function makeStyles(c: ThemePalette) {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
// Webmail thread card: outline buttons, h-9, rounded-md.
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.full,
minHeight: 36,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: 'transparent',
},
actionLabel: { ...typography.caption, color: c.textSecondary },
actionLabel: { ...typography.bodyMedium, color: c.text },
});
}
+1 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as WebBrowser from 'expo-web-browser';
import { MailMinus, CheckCircle2, AlertCircle, X } from 'lucide-react-native';
import { MailMinus, CheckCircle2, AlertCircle, X } from '../icons';
import type { Email } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
+4 -2
View File
@@ -60,16 +60,18 @@ export interface EmlPreview {
date?: string;
html: string | null;
text: string | null;
/** The message's own attachments (not the images its body embeds). */
attachments: ExtractedAttachment[];
}
/** Parse a message once for the preview: its unwrapped body plus the header lines shown above it. */
export async function emlPreviewFromBytes(bytes: Uint8Array): Promise<EmlPreview> {
const parsed = await parseMessage(bytes);
const { html, text } = unwrapParsed(parsed);
const { html, text, attachments } = unwrapParsed(parsed);
const from = parsed.from && 'address' in parsed.from
? `${parsed.from.name ? `${parsed.from.name} ` : ''}<${parsed.from.address}>`
: parsed.from?.name;
return { subject: parsed.subject, from, date: parsed.date, html, text };
return { subject: parsed.subject, from, date: parsed.date, html, text, attachments };
}
function unwrapParsed(parsed: ParsedEmail): UnwrappedMessage {
+276
View File
@@ -0,0 +1,276 @@
import React from 'react';
import { Alert, Platform } from 'react-native';
import { File } from 'expo-file-system';
import type { Email } from '../../api/types';
import {
cachePreviewFile, copyPreviewFile, discardPreviewFile, saveLocalFileCopy, shareAttachment,
shareBytes, shareLocalFile, writePreviewFile,
} from '../../lib/email-export';
import {
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
} from '../../lib/file-preview';
import type { PreviewKind } from '../../lib/file-preview';
import { markdownToHtml } from '../../lib/markdown-html';
import { imageViewerHtml, mediaViewerHtml, pdfViewerHtml, type ViewerColors } from '../../lib/preview-viewer-html';
import { closePdfPages, openPdfPages, PdfOpenError } from '../../lib/pdf-pages';
import { openZip, ZipEncryptedError } from '../../lib/zip-preview';
import { useLocaleStore } from '../../stores/locale-store';
import { useColors, useResolvedTheme } from '../../theme/colors';
import type { PreviewItem } from './AttachmentPreviewModal';
import { emlPreviewFromBytes } from './use-body-override';
export const PREVIEW_OPTIONS: FilePreviewOptions = { platform: Platform.OS === 'ios' ? 'ios' : 'android' };
interface PreviewSourceBase {
/** Display name, shown in the header and given to the cached copy. */
name: string;
type: string;
size?: number | null;
/** Name for "Download" (the attachment filename template); defaults to `name`. */
saveName?: string;
}
/** Where a previewed file comes from. */
export type PreviewSource = PreviewSourceBase & (
| { from: 'blob'; blobId: string; accountId?: string; email?: Email | null }
| { from: 'bytes'; bytes: Uint8Array }
| { from: 'local'; uri: string }
);
/** The preview a tap on `source` opens, or 'none' when it goes to another app. */
export function previewKindOf(source: Pick<PreviewSource, 'name' | 'type' | 'size'>): PreviewKind {
return inAppPreviewKind(source, PREVIEW_OPTIONS);
}
/** Copy the source into a preview folder of its own (see cachePreviewFile). */
async function materialize(source: PreviewSource): Promise<File> {
switch (source.from) {
case 'blob':
return cachePreviewFile(source.blobId, source.name, source.type, source.accountId);
case 'bytes':
return writePreviewFile(source.bytes, source.name, source.type);
case 'local':
return copyPreviewFile(source.uri, source.name, source.type);
}
}
/** Hand a file the app doesn't show to the app that owns its type ("Open with"). */
function handOff(source: PreviewSource): Promise<void> {
switch (source.from) {
case 'blob':
return shareAttachment(source.blobId, source.name, source.type, source.email, source.accountId);
case 'bytes':
return shareBytes(source.bytes, source.name, source.type);
case 'local':
// A copy, so the cleanup after the hand-off never removes the original.
return shareLocalFile(copyPreviewFile(source.uri, source.name, source.type), source.type, source.name);
}
}
// The viewer page sits next to the file it shows (see preview-viewer-html).
function writeViewer(local: File, html: string): string {
const page = new File(local.parentDirectory, '__preview.html');
if (page.exists) page.delete();
page.create();
page.write(html);
return page.uri;
}
interface Notices {
tooLarge: string;
pdfProtected: string;
pdfUnreadable: string;
zipProtected: string;
zipUnreadable: string;
}
async function buildItem(
kind: Exclude<PreviewKind, 'none'>,
source: PreviewSource,
local: File,
notices: Notices,
colors: ViewerColors,
): Promise<PreviewItem> {
const base = { name: source.name, mimeType: source.type };
// The source may not know its size; the copy does.
if (exceedsPreviewLimit(kind, local.size)) return { ...base, kind: 'none', notice: notices.tooLarge };
switch (kind) {
case 'text':
return { ...base, kind, text: await local.text() };
case 'markdown':
return { ...base, kind, html: markdownToHtml(await local.text()) };
case 'html':
return { ...base, kind, html: await local.text() };
case 'eml':
return { ...base, kind, eml: await emlPreviewFromBytes(await local.bytes()) };
case 'archive':
try {
return { ...base, kind, archive: await openZip(await local.bytes()) };
} catch (e) {
return { ...base, kind: 'none', notice: e instanceof ZipEncryptedError ? notices.zipProtected : notices.zipUnreadable };
}
case 'image':
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, imageViewerHtml(local.name, colors)) };
case 'video':
case 'audio':
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, mediaViewerHtml(kind, local.name, colors)) };
case 'pdf': {
// iOS's WebView shows the PDF itself; Android renders its pages.
if (Platform.OS !== 'android') return { ...base, kind, fileUri: local.uri };
try {
const pages = await openPdfPages(local.uri);
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, pdfViewerHtml(local.name, pages, colors)) };
} catch (e) {
const protectedPdf = e instanceof PdfOpenError && e.reason === 'protected';
return { ...base, kind: 'none', notice: protectedPdf ? notices.pdfProtected : notices.pdfUnreadable };
}
}
default:
return { ...base, kind, fileUri: local.uri };
}
}
/**
* One in-app preview: picks how a file is shown, downloads it once into a
* temp folder of its own, and serves Open with, Share and Download from that
* copy. Files the app can't show go straight to another app. The copy is
* deleted when the preview closes or the next one opens, unless another app
* got it, which may still be reading it; the stale-export sweep removes
* those. Used by every place that opens a file: the reader's chips, the
* list's chips, the Files tab and compose.
*
* Render `<AttachmentPreviewModal {...preview.modalProps} />` next to it.
*/
export function useFilePreview(opts: {
/** Called when the preview closes: the close button, or a download that failed. */
onClose?: () => void;
} = {}) {
const t = useLocaleStore((s) => s.t);
const c = useColors();
const scheme = useResolvedTheme();
// Read when a preview is built; the viewer page keeps the theme it opened with.
const viewerColors: ViewerColors = { scheme, background: c.background, text: c.text, textMuted: c.textMuted, surface: c.surface };
const colors = React.useRef(viewerColors);
colors.current = viewerColors;
const onCloseRef = React.useRef(opts.onClose);
onCloseRef.current = opts.onClose;
const [item, setItem] = React.useState<PreviewItem | null>(null);
const [loading, setLoading] = React.useState(false);
const [title, setTitle] = React.useState('');
const source = React.useRef<PreviewSource | null>(null);
const session = React.useRef<{ file: File; handedOff: boolean } | null>(null);
// Bumped whenever a preview ends, so a download that finishes after its
// preview was closed is dropped instead of reopening it.
const seq = React.useRef(0);
const busy = React.useRef(false);
const end = React.useCallback(() => {
seq.current += 1;
const open = session.current;
session.current = null;
if (!open) return;
closePdfPages(open.file.uri);
if (!open.handedOff) discardPreviewFile(open.file);
}, []);
React.useEffect(() => end, [end]);
/** Close without telling the owner (it closed the preview itself). */
const reset = React.useCallback(() => {
end();
source.current = null;
setItem(null);
setLoading(false);
}, [end]);
const close = React.useCallback(() => {
reset();
onCloseRef.current?.();
}, [reset]);
/**
* Show `src` in the preview, or hand it to another app when the preview
* can't show it. The promise settles once that hand-off is done (and
* rejects when it fails); a preview resolves at once.
*/
const open = React.useCallback((src: PreviewSource): Promise<void> => {
const kind = previewKindOf(src);
if (kind === 'none') return handOff(src);
end();
source.current = src;
const mine = seq.current;
const current = () => mine === seq.current;
setTitle(src.name);
setItem(null);
setLoading(true);
void (async () => {
try {
const local = await materialize(src);
if (!current()) {
discardPreviewFile(local);
return;
}
session.current = { file: local, handedOff: false };
const next = await buildItem(kind, src, local, {
tooLarge: t('files.preview_too_large', 'This file is too large to preview.'),
pdfProtected: t(
'email_viewer.preview.pdf_protected',
'This PDF is password-protected. Open it in another app to enter the password.',
),
pdfUnreadable: t('email_viewer.preview.pdf_unreadable', "This PDF can't be shown here."),
zipProtected: t(
'email_viewer.preview.archive_protected',
'This archive is password-protected. Open it in another app to enter the password.',
),
zipUnreadable: t('email_viewer.preview.archive_unreadable', "This archive can't be opened here."),
}, colors.current);
if (current()) setItem(next);
// Closed while the PDF was opening: end() ran before the document was.
else closePdfPages(local.uri);
} catch (e) {
if (!current()) return;
close();
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
} finally {
if (current()) setLoading(false);
}
})();
return Promise.resolve();
}, [end, close, t]);
// The preview's own buttons, all on the downloaded copy.
const act = React.useCallback((run: (local: File, src: PreviewSource) => Promise<void>, handsOff: boolean, failTitle: string) => {
const open = session.current;
const src = source.current;
if (!open || !src || busy.current) return;
busy.current = true;
// Another app may keep reading the file after the sheet or viewer returns.
if (handsOff) open.handedOff = true;
run(open.file, src)
.catch((e) => Alert.alert(failTitle, e instanceof Error ? e.message : String(e)))
.finally(() => { busy.current = false; });
}, []);
const modalProps = React.useMemo(() => ({
item,
loading,
title,
onClose: close,
onOpenExternal: () => act(
(local, src) => shareLocalFile(local, src.type, src.name, { keep: true }),
true,
t('files.open_error', 'Could not open the file'),
),
onShare: () => act(
(local, src) => shareLocalFile(local, src.type, src.name, { forceSheet: true, keep: true }),
true,
t('files.share_file_error', 'Could not share the file'),
),
onDownload: () => act(
(local, src) => saveLocalFileCopy(local, src.type, src.saveName ?? src.name),
false,
t('files.download_error', 'Failed to download'),
),
}), [item, loading, title, close, act, t]);
return { open, close, reset, modalProps };
}
+15 -116
View File
@@ -1,49 +1,31 @@
import React from 'react';
import { Alert, Platform } from 'react-native';
import type { File } from 'expo-file-system';
import type { FileNode } from '../../api/types';
import {
cachePreviewFile, discardPreviewFile, saveLocalFileCopy, shareLocalFile,
} from '../../lib/email-export';
import {
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
} from '../../lib/file-preview';
import { useLocaleStore } from '../../stores/locale-store';
import { AttachmentPreviewModal, type PreviewItem } from '../email/AttachmentPreviewModal';
import { emlPreviewFromBytes } from '../email/use-body-override';
const PREVIEW_OPTIONS: FilePreviewOptions = { inlinePdf: Platform.OS === 'ios' };
import { AttachmentPreviewModal } from '../email/AttachmentPreviewModal';
import { previewKindOf, useFilePreview } from '../email/use-file-preview';
export type PreviewableFileNode = Pick<FileNode, 'name' | 'type' | 'size' | 'blobId' | 'accountId'>;
/** Whether a tap on this file opens the in-app preview (else the OS chooser, as before). */
export function canPreviewInApp(node: PreviewableFileNode): boolean {
return !!node.blobId && inAppPreviewKind(node, PREVIEW_OPTIONS) !== 'none';
return !!node.blobId && previewKindOf({ name: node.name, type: node.type || '', size: node.size }) !== 'none';
}
interface Props {
/** The file to show; null closes the preview. */
file: PreviewableFileNode | null;
onClose: () => void;
/** Show it in a side pane instead of a full-screen modal. */
docked?: boolean;
}
/**
* The Files tab's in-app preview: the reader's attachment preview fed from a
* FileNode. The file is downloaded once, with the same authenticated
* download as "Download" (token refresh, client certificate), into its own
* temp folder; Open with, Share and Download then all use that copy instead
* of fetching it again. The copy is deleted when the preview closes, unless
* another app got it, in which case the stale-export sweep removes it.
* The Files tab's in-app preview: the shared file preview fed from a
* FileNode, downloaded with the same authenticated download as "Download"
* (token refresh, client certificate).
*/
export function FilePreviewModal({ file, onClose }: Props) {
const t = useLocaleStore((s) => s.t);
const [item, setItem] = React.useState<PreviewItem | null>(null);
const [loading, setLoading] = React.useState(false);
// The downloaded copy of the open preview.
const session = React.useRef<{ file: File; handedOff: boolean } | null>(null);
const busy = React.useRef(false);
const onCloseRef = React.useRef(onClose);
onCloseRef.current = onClose;
export function FilePreviewModal({ file, onClose, docked = false }: Props) {
const preview = useFilePreview({ onClose });
const { open, reset } = preview;
const blobId = file?.blobId ?? null;
const accountId = file?.accountId;
@@ -53,92 +35,9 @@ export function FilePreviewModal({ file, onClose }: Props) {
React.useEffect(() => {
if (!blobId) return undefined;
let cancelled = false;
setItem(null);
setLoading(true);
void open({ from: 'blob', blobId, accountId, name, type, size });
return reset;
}, [blobId, accountId, name, type, size, open, reset]);
const build = async (local: File): Promise<PreviewItem> => {
const kind = inAppPreviewKind({ name, type, size }, PREVIEW_OPTIONS);
// The node may not report a size; the download does.
if (kind === 'none' || exceedsPreviewLimit(kind, local.size)) {
return {
kind: 'none',
name,
mimeType: type,
notice: kind === 'none' ? undefined : t('files.preview_too_large', 'This file is too large to preview.'),
};
}
switch (kind) {
case 'text':
return { kind, name, mimeType: type, text: await local.text() };
case 'eml':
return { kind, name, mimeType: type, eml: await emlPreviewFromBytes(await local.bytes()) };
default:
return { kind, name, mimeType: type, fileUri: local.uri };
}
};
void (async () => {
try {
const local = await cachePreviewFile(blobId, name, type, accountId);
if (cancelled) {
discardPreviewFile(local);
return;
}
session.current = { file: local, handedOff: false };
const next = await build(local);
if (!cancelled) setItem(next);
} catch (e) {
if (cancelled) return;
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
onCloseRef.current();
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
const open = session.current;
session.current = null;
if (open && !open.handedOff) discardPreviewFile(open.file);
setItem(null);
setLoading(false);
};
}, [blobId, accountId, name, type, size, t]);
const act = (run: (local: File) => Promise<void>, handsOff: boolean, failTitle: string) => {
const open = session.current;
if (!open || busy.current) return;
busy.current = true;
// Another app may keep reading the file after the sheet or viewer returns.
if (handsOff) open.handedOff = true;
run(open.file)
.catch((e) => Alert.alert(failTitle, e instanceof Error ? e.message : String(e)))
.finally(() => { busy.current = false; });
};
return (
<AttachmentPreviewModal
item={item}
loading={loading}
title={name}
onClose={onClose}
onOpenExternal={() => act(
(local) => shareLocalFile(local, type, name, { keep: true }),
true,
t('files.open_error', 'Could not open the file'),
)}
onShare={() => act(
(local) => shareLocalFile(local, type, name, { forceSheet: true, keep: true }),
true,
t('files.share_file_error', 'Could not share the file'),
)}
onDownload={() => act(
(local) => saveLocalFileCopy(local, type),
false,
t('files.download_error', 'Failed to download'),
)}
/>
);
return <AttachmentPreviewModal {...preview.modalProps} docked={docked} />;
}
+26 -6
View File
@@ -11,7 +11,7 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Folder, FileText, Trash2, UserPlus, Users } from 'lucide-react-native';
import { Folder, FileText, Trash2, UserPlus, Users } from '../icons';
import { isFolder, listPrincipals, ownPrincipalId, setFileNodeShare, type PrincipalListing } from '../../api/files';
import type { FileNode, FileNodeRights, Principal } from '../../api/types';
@@ -20,6 +20,8 @@ import { fileErrorMessage } from '../../lib/file-errors';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type RolePreset = 'read' | 'readWrite' | 'manager';
@@ -67,6 +69,8 @@ interface ShareSheetProps {
}
export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -107,6 +111,9 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
);
const candidates = useMemo(() => {
// The sheet is mounted with the Files tab, before a session may exist
// (an offline cold start): nobody to list yet, and no own id to read.
if (principals.length === 0) return [];
const self = ownPrincipalId();
const q = search.trim().toLowerCase();
return principals
@@ -178,10 +185,10 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
};
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={styles.titleRow}>
<Icon size={18} color={c.textMuted} />
@@ -233,8 +240,21 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Pressable
onPress={() => void applyShare(principalId, null)}
// Revoking access is immediate on the server: ask first.
onPress={() => Alert.alert(
t('files.share_remove_confirm', 'Remove access?'),
undefined,
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{
text: t('files.share_remove', 'Remove access'),
style: 'destructive',
onPress: () => { void applyShare(principalId, null); },
},
],
)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('files.share_remove', 'Remove access')}
>
<Trash2 size={18} color={c.error} />
+6 -4
View File
@@ -1,10 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Alert,
View, Text, StyleSheet, ScrollView, Pressable, Alert,
KeyboardAvoidingView, Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { X, Plus, Trash2 } from 'lucide-react-native';
import { X, Plus, Trash2 } from '../icons';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Select, ToggleSwitch } from '../settings/settings-section';
@@ -38,6 +38,8 @@ import type {
FilterComparator,
FilterActionType,
} from '../../lib/sieve/types';
import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment'];
const TEXT_COMPARATORS: FilterComparator[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'];
@@ -238,7 +240,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
}, [name, conditions, actions, matchType, stopProcessing, includeSpam, rule, onSave, t, mailboxTargets]);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable
@@ -503,7 +505,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
</Button>
</View>
</SafeAreaView>
</Modal>
</SafeAreaModal>
);
}
+11 -7
View File
@@ -1,14 +1,16 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, TextInput,
View, Text, StyleSheet, ScrollView, Pressable, TextInput,
KeyboardAvoidingView, Platform, ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { X, AlertTriangle, CheckCircle } from 'lucide-react-native';
import { X, AlertTriangle, CheckCircle } from '../icons';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import Button from '../Button';
import { useLocaleStore } from '../../stores/locale-store';
import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
interface SieveEditorSheetProps {
visible: boolean;
@@ -62,7 +64,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
}, [script, showSaveWarning, onSave]);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable
@@ -173,7 +175,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
</View>
</View>
</SafeAreaView>
</Modal>
</SafeAreaModal>
);
}
@@ -205,11 +207,13 @@ function makeStyles(c: ThemePalette) {
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: 'rgba(252, 211, 77, 0.4)',
backgroundColor: 'rgba(120, 53, 15, 0.25)',
// Theme warning tokens: the fixed dark-amber values left light-yellow
// text on a near-white fill in the light theme.
borderColor: c.warning,
backgroundColor: c.warningBg,
alignItems: 'flex-start',
},
warningText: { ...typography.caption, color: '#fde68a', flex: 1 },
warningText: { ...typography.caption, color: c.text, flex: 1 },
editorWrap: {
flex: 1,
@@ -0,0 +1,74 @@
// The app's icons are Tabler glyphs under the names the screens import.
// These tests make sure each name draws the glyph the webmail uses for it,
// and that every icon a screen imports exists.
import { describe, it, expect, vi } from 'vitest';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' }));
const ROOT = join(__dirname, '..', '..', '..', '..');
const WEBMAIL_ICONS = join(ROOT, '..', '..', 'components', 'icons', 'index.ts');
const TABLER = join(ROOT, '..', '..', 'node_modules', '@tabler', 'icons', 'tabler-nodes-outline.json');
function sourceFiles(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (statSync(p).isDirectory()) {
// The widgets draw their own SVG strings (src/widgets/icons.ts).
if (entry !== '__tests__' && entry !== 'node_modules' && entry !== 'widgets') sourceFiles(p, out);
} else if (/\.tsx?$/.test(entry)) {
out.push(p);
}
}
return out;
}
/** Path data the generated module hands to an icon, read off its element. */
function glyphOf(icon: unknown): string[] {
const memo = icon as { type: (p: object) => { props: { children: { props: { d: string } }[] } } };
return memo.type({}).props.children.map((child) => child.props.d);
}
describe('icons', () => {
it('exports every icon a screen imports', async () => {
const icons: Record<string, unknown> = await import('..');
const missing: string[] = [];
const files = [...sourceFiles(join(ROOT, 'src')), join(ROOT, 'App.tsx')];
const re = /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+'[./]*(?:components\/)?icons'/g;
let checked = 0;
for (const file of files) {
for (const m of readFileSync(file, 'utf8').matchAll(re)) {
for (const raw of m[1].split(',')) {
const spec = raw.trim().replace(/^type\s+/, '');
if (!spec) continue;
const name = spec.split(/\s+as\s+/)[0];
if (name === 'AppIcon' || name === 'IconProps') continue;
checked++;
if (typeof icons[name] !== 'object') missing.push(`${relative(ROOT, file)}: ${name}`);
}
}
}
expect(missing).toEqual([]);
expect(checked).toBeGreaterThan(100);
});
it.skipIf(!existsSync(WEBMAIL_ICONS) || !existsSync(TABLER))('draws the glyph the webmail uses for the same name', async () => {
const icons: Record<string, unknown> = await import('..');
const tabler: Record<string, [string, { d: string }][]> = JSON.parse(readFileSync(TABLER, 'utf8'));
const webmail = readFileSync(WEBMAIL_ICONS, 'utf8');
const kebab = (pascal: string) => pascal.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Za-z])(\d)/g, '$1-$2').toLowerCase();
const differ: string[] = [];
let compared = 0;
for (const m of webmail.matchAll(/Icon(\w+) as (\w+),/g)) {
const [, tablerPascal, name] = m;
if (!(name in icons)) continue;
const expected = tabler[kebab(tablerPascal)]?.map(([, a]) => a.d);
if (!expected) continue;
compared++;
if (JSON.stringify(glyphOf(icons[name])) !== JSON.stringify(expected)) differ.push(`${name} (webmail: ${kebab(tablerPascal)})`);
}
expect(differ).toEqual([]);
expect(compared).toBeGreaterThan(100);
});
});
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import type { StyleProp, ViewStyle } from 'react-native';
import Svg, { Path } from 'react-native-svg';
export interface IconProps {
size?: number;
color?: string;
/** Tabler draws at 2 on a 24 grid, like the webmail. */
strokeWidth?: number;
/** Fills the outline, for a starred star or a coloured tag. */
fill?: string;
style?: StyleProp<ViewStyle>;
testID?: string;
}
export type AppIcon = React.ComponentType<IconProps>;
/** An icon component for one Tabler outline glyph (its path data). */
export function makeIcon(name: string, paths: readonly string[]): AppIcon {
if (!paths?.length) throw new Error(`Icon ${name} has no glyph`);
function Icon({ size = 24, color = '#000', strokeWidth = 2, fill = 'none', style, testID }: IconProps) {
return (
<Svg
width={size}
height={size}
viewBox="0 0 24 24"
fill={fill}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
style={style}
testID={testID}
>
{paths.map((d, i) => <Path key={i} d={d} />)}
</Svg>
);
}
Icon.displayName = name;
return React.memo(Icon);
}
+361
View File
@@ -0,0 +1,361 @@
// Generated by scripts/generate-icons.mjs from @tabler/icons (MIT).
// Do not edit by hand: add the icon to MAP in the script and run it.
import { makeIcon } from './icon';
export type { AppIcon, IconProps } from './icon';
const G = {
'address-book': ["M20 6v12a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2","M10 16h6","M11 11a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M4 8h3","M4 12h3","M4 16h3"],
'alarm': ["M5 13a7 7 0 1 0 14 0a7 7 0 1 0 -14 0","M12 10l0 3l2 0","M7 4l-2.75 2","M17 4l2.75 2"],
'alert-circle': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M12 8v4","M12 16h.01"],
'alert-triangle': ["M12 9v4","M10.363 3.591l-8.106 13.534a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636 -2.87l-8.106 -13.536a1.914 1.914 0 0 0 -3.274 0","M12 16h.01"],
'align-center': ["M4 6l16 0","M8 12l8 0","M6 18l12 0"],
'align-left': ["M4 6l16 0","M4 12l10 0","M4 18l14 0"],
'align-right': ["M4 6l16 0","M10 12l10 0","M6 18l14 0"],
'archive': ["M3 6a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2","M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10","M10 12l4 0"],
'arrow-back': ["M9 11l-4 4l4 4m-4 -4h11a4 4 0 0 0 0 -8h-1"],
'arrow-back-up': ["M9 14l-4 -4l4 -4","M5 10h11a4 4 0 1 1 0 8h-1"],
'arrow-back-up-double': ["M13 14l-4 -4l4 -4","M8 14l-4 -4l4 -4","M9 10h7a4 4 0 1 1 0 8h-1"],
'arrow-down': ["M12 5l0 14","M18 13l-6 6","M6 13l6 6"],
'arrow-forward': ["M15 11l4 4l-4 4m4 -4h-11a4 4 0 0 1 0 -8h1"],
'arrow-forward-up': ["M15 14l4 -4l-4 -4","M19 10h-11a4 4 0 1 0 0 8h1"],
'arrow-left': ["M5 12l14 0","M5 12l6 6","M5 12l6 -6"],
'arrow-right': ["M5 12l14 0","M13 18l6 -6","M13 6l6 6"],
'arrow-up': ["M12 5l0 14","M18 11l-6 -6","M6 11l6 -6"],
'arrows-shuffle': ["M18 4l3 3l-3 3","M18 20l3 -3l-3 -3","M3 7h3a5 5 0 0 1 5 5a5 5 0 0 0 5 5h5","M21 7h-5a4.978 4.978 0 0 0 -3 1m-4 8a4.984 4.984 0 0 1 -3 1h-3"],
'arrows-sort': ["M3 9l4 -4l4 4m-4 -4v14","M21 15l-4 4l-4 -4m4 4v-14"],
'arrows-vertical': ["M8 7l4 -4l4 4","M8 17l4 4l4 -4","M12 3l0 18"],
'ban': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M5.7 5.7l12.6 12.6"],
'beach': ["M17.553 16.75a7.5 7.5 0 0 0 -10.606 0","M18 3.804a6 6 0 0 0 -8.196 2.196l10.392 6a6 6 0 0 0 -2.196 -8.196","M16.732 10c1.658 -2.87 2.225 -5.644 1.268 -6.196c-.957 -.552 -3.075 1.326 -4.732 4.196","M15 9l-3 5.196","M3 19.25a2.4 2.4 0 0 1 1 -.25a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 1 .25"],
'bell': ["M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1"],
'bell-off': ["M9.346 5.353c.21 -.129 .428 -.246 .654 -.353a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3m-1 3h-13a4 4 0 0 0 2 -3v-3a6.996 6.996 0 0 1 1.273 -3.707","M9 17v1a3 3 0 0 0 6 0v-1","M3 3l18 18"],
'bell-ringing': ["M10 5a2 2 0 0 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1","M21 6.727a11.05 11.05 0 0 0 -2.794 -3.727","M3 6.727a11.05 11.05 0 0 1 2.792 -3.727"],
'bold': ["M7 5h6a3.5 3.5 0 0 1 0 7h-6l0 -7","M13 12h1a3.5 3.5 0 0 1 0 7h-7v-7"],
'book': ["M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6l0 13","M12 6l0 13","M21 6l0 13"],
'book-2': ["M19 4v16h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12","M19 16h-12a2 2 0 0 0 -2 2","M9 8h6"],
'bug': ["M9 9v-1a3 3 0 0 1 6 0v1","M8 9h8a6 6 0 0 1 1 3v3a5 5 0 0 1 -10 0v-3a6 6 0 0 1 1 -3","M3 13l4 0","M17 13l4 0","M12 20l0 -6","M4 19l3.35 -2","M20 19l-3.35 -2","M4 7l3.75 2.4","M20 7l-3.75 2.4"],
'building': ["M3 21l18 0","M9 8l1 0","M9 12l1 0","M9 16l1 0","M14 8l1 0","M14 12l1 0","M14 16l1 0","M5 21v-16a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v16"],
'cake': ["M3 20h18v-8a3 3 0 0 0 -3 -3h-12a3 3 0 0 0 -3 3v8","M3 14.803c.312 .135 .654 .204 1 .197a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1c.35 .007 .692 -.062 1 -.197","M12 4l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737"],
'calendar': ["M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12","M16 3v4","M8 3v4","M4 11h16","M11 15h1","M12 15v3"],
'calendar-month': ["M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12","M16 3v4","M8 3v4","M4 11h16","M8 14v4","M12 14v4","M16 14v4"],
'calendar-plus': ["M12.5 21h-6.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v5","M16 3v4","M8 3v4","M4 11h16","M16 19h6","M19 16v6"],
'camera': ["M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2","M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"],
'check': ["M5 12l5 5l10 -10"],
'checks': ["M7 12l5 5l10 -10","M2 12l5 5m5 -5l5 -5"],
'chevron-down': ["M6 9l6 6l6 -6"],
'chevron-left': ["M15 6l-6 6l6 6"],
'chevron-right': ["M9 6l6 6l-6 6"],
'chevron-up': ["M6 15l6 -6l6 6"],
'circle-check': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M9 12l2 2l4 -4"],
'circle-x': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M10 10l4 4m0 -4l-4 4"],
'clear-formatting': ["M17 15l4 4m0 -4l-4 4","M7 6v-1h11v1","M7 19l4 0","M13 5l-4 14"],
'clipboard-text': ["M9 5h-2a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2h-2","M9 5a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2","M9 12h6","M9 16h6"],
'clock': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M12 7v5l3 3"],
'cloud-download': ["M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4","M12 13l0 9","M9 19l3 3l3 -3"],
'cloud-off': ["M9.58 5.548c.24 -.11 .492 -.207 .752 -.286c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 .957 -.383 1.824 -1.003 2.454m-2.997 1.033h-11.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.13 -.582 .37 -1.128 .7 -1.62","M3 3l18 18"],
'code': ["M7 8l-4 4l4 4","M17 8l4 4l-4 4","M14 4l-4 16"],
'columns-3': ["M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16","M9 3v18","M15 3v18"],
'copy': ["M7 9.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667l0 -8.666","M4.012 16.737a2.005 2.005 0 0 1 -1.012 -1.737v-10c0 -1.1 .9 -2 2 -2h10c.75 0 1.158 .385 1.5 1"],
'database': ["M4 6a8 3 0 1 0 16 0a8 3 0 1 0 -16 0","M4 6v6a8 3 0 0 0 16 0v-6","M4 12v6a8 3 0 0 0 16 0v-6"],
'device-desktop': ["M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10","M7 20h10","M9 16v4","M15 16v4"],
'device-floppy': ["M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2","M10 14a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M14 4l0 4l-6 0l0 -4"],
'device-mobile': ["M6 5a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2v-14","M11 4h2","M12 17v.01"],
'dots': ["M4 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M18 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
'dots-vertical': ["M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
'download': ["M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2","M7 11l5 5l5 -5","M12 4l0 12"],
'edit': ["M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1","M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415","M16 5l3 3"],
'eraser': ["M19 20h-10.5l-4.21 -4.3a1 1 0 0 1 0 -1.41l10 -10a1 1 0 0 1 1.41 0l5 5a1 1 0 0 1 0 1.41l-9.2 9.3","M18 13.3l-6.3 -6.3"],
'external-link': ["M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6","M11 13l9 -9","M15 4h5v5"],
'eye': ["M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0","M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"],
'eye-off': ["M10.585 10.587a2 2 0 0 0 2.829 2.828","M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87","M3 3l18 18"],
'file': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"],
'file-code': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2","M10 13l-1 2l1 2","M14 13l1 2l-1 2"],
'file-code-2': ["M10 12h-1v5h1","M14 12h1v5h-1","M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"],
'file-import': ["M14 3v4a1 1 0 0 0 1 1h4","M5 13v-8a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2h-5.5m-9.5 -2h7m-3 -3l3 3l-3 3"],
'file-music': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2","M10 16a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M12 16l0 -5l2 1"],
'file-spreadsheet': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2","M8 11h8v7h-8l0 -7","M8 15h8","M11 11v7"],
'file-symlink': ["M4 21v-4a3 3 0 0 1 3 -3h5","M9 17l3 -3l-3 -3","M14 3v4a1 1 0 0 0 1 1h4","M5 11v-6a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2h-9.5"],
'file-text': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2","M9 9l1 0","M9 13l6 0","M9 17l6 0"],
'file-zip': ["M6 20.735a2 2 0 0 1 -1 -1.735v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2h-1","M11 17a2 2 0 0 1 2 2v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a2 2 0 0 1 2 -2","M11 5l-1 0","M13 7l-1 0","M11 9l-1 0","M13 11l-1 0","M11 13l-1 0","M13 15l-1 0"],
'filter': ["M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227"],
'flag': ["M5 5a5 5 0 0 1 7 0a5 5 0 0 0 7 0v9a5 5 0 0 1 -7 0a5 5 0 0 0 -7 0v-9","M5 21v-7"],
'flask': ["M9 3l6 0","M10 9l4 0","M10 3v6l-4 11a.7 .7 0 0 0 .5 1h11a.7 .7 0 0 0 .5 -1l-4 -11v-6"],
'folder': ["M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2"],
'folder-down': ["M12 19h-7a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v3.5","M19 16v6","M22 19l-3 3l-3 -3"],
'folder-open': ["M5 19l2.757 -7.351a1 1 0 0 1 .936 -.649h12.307a1 1 0 0 1 .986 1.164l-.996 5.211a2 2 0 0 1 -1.964 1.625h-14.026a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2"],
'folder-plus': ["M12 19h-7a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v3.5","M16 19h6","M19 16v6"],
'folders': ["M9 3h3l2 2h5a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2","M17 16v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2h2"],
'grid-dots': ["M4 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M18 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M4 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M18 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M4 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M18 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
'grip-vertical': ["M8 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M8 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M8 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M14 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M14 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M14 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
'h-1': ["M19 18v-8l-2 2","M4 6v12","M12 6v12","M11 18h2","M3 18h2","M4 12h8","M3 6h2","M11 6h2"],
'h-2': ["M17 12a2 2 0 1 1 4 0c0 .591 -.417 1.318 -.816 1.858l-3.184 4.143l4 0","M4 6v12","M12 6v12","M11 18h2","M3 18h2","M4 12h8","M3 6h2","M11 6h2"],
'heart': ["M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572"],
'help-circle': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M12 16v.01","M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"],
'highlight': ["M3 19h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4","M12.5 5.5l4 4","M4.5 13.5l4 4","M21 15v4h-8l4 -4l4 0"],
'inbox': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M4 13h3l3 3h4l3 -3h3"],
'info-circle': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M12 9h.01","M11 12h1v4h1"],
'italic': ["M11 5l6 0","M7 19l6 0","M14 5l-4 14"],
'key': ["M16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1 -4.069 0l-.301 -.301l-6.558 6.558a2 2 0 0 1 -1.239 .578l-.175 .008h-1.172a1 1 0 0 1 -.993 -.883l-.007 -.117v-1.172a2 2 0 0 1 .467 -1.284l.119 -.13l.414 -.414h2v-2h2v-2l2.144 -2.144l-.301 -.301a2.877 2.877 0 0 1 0 -4.069l2.643 -2.643a2.877 2.877 0 0 1 4.069 0","M15 9h.01"],
'language': ["M9 6.371c0 4.418 -2.239 6.629 -5 6.629","M4 6.371h7","M5 9c0 2.144 2.252 3.908 6 4","M12 20l4 -9l4 9","M19.1 18h-6.2","M6.694 3l.793 .582"],
'layout-columns': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M12 4l0 16"],
'layout-grid': ["M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M14 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M14 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4"],
'layout-rows': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M4 12l16 0"],
'layout-sidebar-left-collapse': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M9 4v16","M15 10l-2 2l2 2"],
'layout-sidebar-right': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M15 4l0 16"],
'link': ["M9 15l6 -6","M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464","M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"],
'list': ["M9 6l11 0","M9 12l11 0","M9 18l11 0","M5 6l0 .01","M5 12l0 .01","M5 18l0 .01"],
'list-check': ["M3.5 5.5l1.5 1.5l2.5 -2.5","M3.5 11.5l1.5 1.5l2.5 -2.5","M3.5 17.5l1.5 1.5l2.5 -2.5","M11 6l9 0","M11 12l9 0","M11 18l9 0"],
'list-numbers': ["M11 6h9","M11 12h9","M12 18h8","M4 16a2 2 0 1 1 4 0c0 .591 -.5 1 -1 1.5l-3 2.5h4","M6 10v-6l-2 2"],
'lock': ["M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6","M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0","M8 11v-4a4 4 0 1 1 8 0v4"],
'logout': ["M14 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2","M9 12h12l-3 -3","M18 15l3 -3"],
'mail': ["M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10","M3 7l9 6l9 -6"],
'mail-check': ["M11 19h-6a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v6","M3 7l9 6l9 -6","M15 19l2 2l4 -4"],
'mail-minus': ["M12 19h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v8","M16 19h6","M3 7l9 6l9 -6"],
'mail-opened': ["M3 9l9 6l9 -6l-9 -6l-9 6","M21 9v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10","M3 19l6 -6","M15 13l6 6"],
'map-pin': ["M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0","M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0"],
'maximize': ["M4 8v-2a2 2 0 0 1 2 -2h2","M4 16v2a2 2 0 0 0 2 2h2","M16 4h2a2 2 0 0 1 2 2v2","M16 20h2a2 2 0 0 0 2 -2v-2"],
'menu-2': ["M4 6l16 0","M4 12l16 0","M4 18l16 0"],
'message': ["M8 9h8","M8 13h6","M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12"],
'minus': ["M5 12l14 0"],
'moon': ["M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454l0 .008"],
'note': ["M13 20l7 -7","M13 20v-6a1 1 0 0 1 1 -1h6v-7a2 2 0 0 0 -2 -2h-12a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7"],
'palette': ["M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25","M7.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M15.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
'paperclip': ["M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"],
'pencil': ["M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4","M13.5 6.5l4 4"],
'phone': ["M5 4h4l2 5l-2.5 1.5a11 11 0 0 0 5 5l1.5 -2.5l5 2v4a2 2 0 0 1 -2 2a16 16 0 0 1 -15 -15a2 2 0 0 1 2 -2"],
'photo': ["M15 8h.01","M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12","M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5","M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"],
'pinned': ["M9 4v6l-2 4v2h10v-2l-2 -4v-6","M12 16l0 5","M8 4l8 0"],
'pinned-off': ["M9 4v1","M9 9v1l-2 4v2h9","M16 12l-1 -2v-6","M12 16v5","M8 4h8","M3 3l18 18"],
'plus': ["M12 5l0 14","M5 12l14 0"],
'puzzle': ["M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1"],
'qrcode': ["M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M7 17l0 .01","M14 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M7 7l0 .01","M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M17 7l0 .01","M14 14l3 0","M20 14l0 .01","M14 14l0 3","M14 20l3 0","M17 17l3 0","M20 17l0 3"],
'quote': ["M10 11h-4a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h3a1 1 0 0 1 1 1v6c0 2.667 -1.333 4.333 -4 5","M19 11h-4a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h3a1 1 0 0 1 1 1v6c0 2.667 -1.333 4.333 -4 5"],
'refresh': ["M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4","M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"],
'repeat': ["M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3","M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3"],
'rotate': ["M19.95 11a8 8 0 1 0 -.5 4m.5 5v-5h-5"],
'rss': ["M4 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M4 4a16 16 0 0 1 16 16","M4 11a9 9 0 0 1 9 9"],
'scale': ["M7 20l10 0","M6 6l6 -1l6 1","M12 3l0 17","M9 12l-3 -6l-3 6a3 3 0 0 0 6 0","M21 12l-3 -6l-3 6a3 3 0 0 0 6 0"],
'search': ["M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0","M21 21l-6 -6"],
'send': ["M10 14l11 -11","M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5"],
'server': ["M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2","M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2","M7 8l0 .01","M7 16l0 .01"],
'server-2': ["M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2","M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2","M7 8l0 .01","M7 16l0 .01","M11 8h6","M11 16h6"],
'settings': ["M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065","M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"],
'share-2': ["M8 9h-1a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-8a2 2 0 0 0 -2 -2h-1","M12 14v-11","M9 6l3 -3l3 3"],
'shield': ["M12 3a12 12 0 0 0 8.5 3a12 12 0 0 1 -8.5 15a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3"],
'shield-check': ["M11.46 20.846a12 12 0 0 1 -7.96 -14.846a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 -.09 7.06","M15 19l2 2l4 -4"],
'shield-exclamation': ["M15.04 19.745c-.942 .551 -1.964 .976 -3.04 1.255a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 .195 6.015","M19 16v3","M19 22v.01"],
'shield-question': ["M15.065 19.732c-.95 .557 -1.98 .986 -3.065 1.268a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3c.51 1.738 .617 3.55 .333 5.303","M19 22v.01","M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"],
'sort-ascending': ["M4 6l7 0","M4 12l7 0","M4 18l9 0","M15 9l3 -3l3 3","M18 6l0 12"],
'sort-descending': ["M4 6l9 0","M4 12l7 0","M4 18l7 0","M15 15l3 3l3 -3","M18 6l0 12"],
'square': ["M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14"],
'square-check': ["M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14","M9 12l2 2l4 -4"],
'stack': ["M12 6l-8 4l8 4l8 -4l-8 -4","M4 14l8 4l8 -4"],
'star': ["M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873l-6.158 -3.245"],
'strikethrough': ["M5 12l14 0","M16 6.5a4 2 0 0 0 -4 -1.5h-1a3.5 3.5 0 0 0 0 7h2a3.5 3.5 0 0 1 0 7h-1.5a4 2 0 0 1 -4 -1.5"],
'sun': ["M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0","M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7"],
'table': ["M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14","M3 10h18","M10 3v18"],
'tag': ["M6.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M3 6v5.172a2 2 0 0 0 .586 1.414l7.71 7.71a2.41 2.41 0 0 0 3.408 0l5.592 -5.592a2.41 2.41 0 0 0 0 -3.408l-7.71 -7.71a2 2 0 0 0 -1.414 -.586h-5.172a3 3 0 0 0 -3 3"],
'tags': ["M3 8v4.172a2 2 0 0 0 .586 1.414l5.71 5.71a2.41 2.41 0 0 0 3.408 0l3.592 -3.592a2.41 2.41 0 0 0 0 -3.408l-5.71 -5.71a2 2 0 0 0 -1.414 -.586h-4.172a2 2 0 0 0 -2 2","M18 19l1.592 -1.592a4.82 4.82 0 0 0 0 -6.816l-4.592 -4.592","M7 10h-.01"],
'template': ["M4 5a1 1 0 0 1 1 -1h14a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1l0 -2","M4 13a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -6","M14 12l6 0","M14 16l6 0","M14 20l6 0"],
'terminal': ["M5 7l5 5l-5 5","M12 19l7 0"],
'trash': ["M4 7l16 0","M10 11l0 6","M14 11l0 6","M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12","M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"],
'typography': ["M4 20l3 0","M14 20l7 0","M6.9 15l6.9 0","M10.2 6.3l5.8 13.7","M5 20l6 -16l2 0l7 16"],
'underline': ["M7 5v5a5 5 0 0 0 10 0v-5","M5 19h14"],
'unlink': ["M17 22v-2","M9 15l6 -6","M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464","M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463","M20 17h2","M2 7h2","M7 2v2"],
'upload': ["M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2","M7 9l5 -5l5 5","M12 4l0 12"],
'user': ["M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0","M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"],
'user-circle': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M9 10a3 3 0 1 0 6 0a3 3 0 1 0 -6 0","M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855"],
'user-edit': ["M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0","M6 21v-2a4 4 0 0 1 4 -4h3.5","M18.42 15.61a2.1 2.1 0 0 1 2.97 2.97l-3.39 3.42h-3v-3l3.42 -3.39"],
'user-minus': ["M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0","M6 21v-2a4 4 0 0 1 4 -4h4c.348 0 .686 .045 1.009 .128","M16 19h6"],
'user-plus': ["M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0","M16 19h6","M19 16v6","M6 21v-2a4 4 0 0 1 4 -4h4"],
'users': ["M5 7a4 4 0 1 0 8 0a4 4 0 1 0 -8 0","M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2","M16 3.13a4 4 0 0 1 0 7.75","M21 21v-2a4 4 0 0 0 -3 -3.85"],
'video': ["M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4","M3 8a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2l0 -8"],
'world': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M3.6 9h16.8","M3.6 15h16.8","M11.5 3a17 17 0 0 0 0 18","M12.5 3a17 17 0 0 1 0 18"],
'x': ["M18 6l-12 12","M6 6l12 12"],
'zoom-scan': ["M8 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0","M16 16l-2.5 -2.5","M3 7v-2a2 2 0 0 1 2 -2h2","M3 17v2a2 2 0 0 0 2 2h2","M17 3h2a2 2 0 0 1 2 2v2","M17 21h2a2 2 0 0 0 2 -2v-2"],
} as const;
export const AlarmClock = makeIcon('AlarmClock', G['alarm']);
export const AlertCircle = makeIcon('AlertCircle', G['alert-circle']);
export const AlertTriangle = makeIcon('AlertTriangle', G['alert-triangle']);
export const AlignCenter = makeIcon('AlignCenter', G['align-center']);
export const AlignLeft = makeIcon('AlignLeft', G['align-left']);
export const AlignRight = makeIcon('AlignRight', G['align-right']);
export const Archive = makeIcon('Archive', G['archive']);
export const ArrowDown = makeIcon('ArrowDown', G['arrow-down']);
export const ArrowDownWideNarrow = makeIcon('ArrowDownWideNarrow', G['sort-descending']);
export const ArrowLeft = makeIcon('ArrowLeft', G['arrow-left']);
export const ArrowRight = makeIcon('ArrowRight', G['arrow-right']);
export const ArrowUp = makeIcon('ArrowUp', G['arrow-up']);
export const ArrowUpDown = makeIcon('ArrowUpDown', G['arrows-sort']);
export const ArrowUpNarrowWide = makeIcon('ArrowUpNarrowWide', G['sort-ascending']);
export const Ban = makeIcon('Ban', G['ban']);
export const Bell = makeIcon('Bell', G['bell']);
export const BellOff = makeIcon('BellOff', G['bell-off']);
export const BellRing = makeIcon('BellRing', G['bell-ringing']);
export const Bold = makeIcon('Bold', G['bold']);
export const Book = makeIcon('Book', G['book-2']);
export const BookOpen = makeIcon('BookOpen', G['book']);
export const BookUser = makeIcon('BookUser', G['address-book']);
export const Bug = makeIcon('Bug', G['bug']);
export const Building = makeIcon('Building', G['building']);
export const Cake = makeIcon('Cake', G['cake']);
export const Calendar = makeIcon('Calendar', G['calendar']);
export const CalendarDays = makeIcon('CalendarDays', G['calendar-month']);
export const CalendarPlus = makeIcon('CalendarPlus', G['calendar-plus']);
export const Camera = makeIcon('Camera', G['camera']);
export const Check = makeIcon('Check', G['check']);
export const CheckCheck = makeIcon('CheckCheck', G['checks']);
export const CheckCircle = makeIcon('CheckCircle', G['circle-check']);
export const CheckCircle2 = makeIcon('CheckCircle2', G['circle-check']);
export const CheckSquare = makeIcon('CheckSquare', G['square-check']);
export const ChevronDown = makeIcon('ChevronDown', G['chevron-down']);
export const ChevronLeft = makeIcon('ChevronLeft', G['chevron-left']);
export const ChevronRight = makeIcon('ChevronRight', G['chevron-right']);
export const ChevronUp = makeIcon('ChevronUp', G['chevron-up']);
export const CircleCheck = makeIcon('CircleCheck', G['circle-check']);
export const ClipboardPaste = makeIcon('ClipboardPaste', G['clipboard-text']);
export const Clock = makeIcon('Clock', G['clock']);
export const CloudDownload = makeIcon('CloudDownload', G['cloud-download']);
export const CloudOff = makeIcon('CloudOff', G['cloud-off']);
export const Code = makeIcon('Code', G['code']);
export const Columns3 = makeIcon('Columns3', G['columns-3']);
export const Columns4 = makeIcon('Columns4', G['layout-columns']);
export const Copy = makeIcon('Copy', G['copy']);
export const Database = makeIcon('Database', G['database']);
export const Download = makeIcon('Download', G['download']);
export const Edit2 = makeIcon('Edit2', G['pencil']);
export const Eraser = makeIcon('Eraser', G['eraser']);
export const ExternalLink = makeIcon('ExternalLink', G['external-link']);
export const Eye = makeIcon('Eye', G['eye']);
export const EyeOff = makeIcon('EyeOff', G['eye-off']);
export const File = makeIcon('File', G['file']);
export const FileArchive = makeIcon('FileArchive', G['file-zip']);
export const FileAudio = makeIcon('FileAudio', G['file-music']);
export const FileCode = makeIcon('FileCode', G['file-code']);
export const FileCode2 = makeIcon('FileCode2', G['file-code-2']);
export const FileImage = makeIcon('FileImage', G['photo']);
export const FileInput = makeIcon('FileInput', G['file-import']);
export const FileSpreadsheet = makeIcon('FileSpreadsheet', G['file-spreadsheet']);
export const FileSymlink = makeIcon('FileSymlink', G['file-symlink']);
export const FileText = makeIcon('FileText', G['file-text']);
export const FileVideo = makeIcon('FileVideo', G['video']);
export const Filter = makeIcon('Filter', G['filter']);
export const Flag = makeIcon('Flag', G['flag']);
export const FlaskConical = makeIcon('FlaskConical', G['flask']);
export const Folder = makeIcon('Folder', G['folder']);
export const FolderArchive = makeIcon('FolderArchive', G['archive']);
export const FolderInput = makeIcon('FolderInput', G['folder-down']);
export const FolderOpen = makeIcon('FolderOpen', G['folder-open']);
export const FolderPlus = makeIcon('FolderPlus', G['folder-plus']);
export const FolderSync = makeIcon('FolderSync', G['folders']);
export const Forward = makeIcon('Forward', G['arrow-forward-up']);
export const Globe = makeIcon('Globe', G['world']);
export const Grid3x3 = makeIcon('Grid3x3', G['grid-dots']);
export const GripVertical = makeIcon('GripVertical', G['grip-vertical']);
export const HardDrive = makeIcon('HardDrive', G['server-2']);
export const Heading1 = makeIcon('Heading1', G['h-1']);
export const Heading2 = makeIcon('Heading2', G['h-2']);
export const Heart = makeIcon('Heart', G['heart']);
export const HelpCircle = makeIcon('HelpCircle', G['help-circle']);
export const Highlighter = makeIcon('Highlighter', G['highlight']);
export const Image = makeIcon('Image', G['photo']);
export const Import = makeIcon('Import', G['file-import']);
export const Inbox = makeIcon('Inbox', G['inbox']);
export const Info = makeIcon('Info', G['info-circle']);
export const Italic = makeIcon('Italic', G['italic']);
export const Key = makeIcon('Key', G['key']);
export const KeyRound = makeIcon('KeyRound', G['key']);
export const Languages = makeIcon('Languages', G['language']);
export const Layers = makeIcon('Layers', G['stack']);
export const LayoutGrid = makeIcon('LayoutGrid', G['layout-grid']);
export const LayoutTemplate = makeIcon('LayoutTemplate', G['template']);
export const Link2 = makeIcon('Link2', G['link']);
export const Link2Off = makeIcon('Link2Off', G['unlink']);
export const List = makeIcon('List', G['list']);
export const ListChecks = makeIcon('ListChecks', G['list-check']);
export const ListOrdered = makeIcon('ListOrdered', G['list-numbers']);
export const Lock = makeIcon('Lock', G['lock']);
export const LogOut = makeIcon('LogOut', G['logout']);
export const Mail = makeIcon('Mail', G['mail']);
export const MailCheck = makeIcon('MailCheck', G['mail-check']);
export const MailMinus = makeIcon('MailMinus', G['mail-minus']);
export const MailOpen = makeIcon('MailOpen', G['mail-opened']);
export const Mails = makeIcon('Mails', G['inbox']);
export const MapPin = makeIcon('MapPin', G['map-pin']);
export const Maximize2 = makeIcon('Maximize2', G['maximize']);
export const Menu = makeIcon('Menu', G['menu-2']);
export const MessageSquare = makeIcon('MessageSquare', G['message']);
export const Minus = makeIcon('Minus', G['minus']);
export const Monitor = makeIcon('Monitor', G['device-desktop']);
export const Moon = makeIcon('Moon', G['moon']);
export const MoreHorizontal = makeIcon('MoreHorizontal', G['dots']);
export const MoreVertical = makeIcon('MoreVertical', G['dots-vertical']);
export const MoveVertical = makeIcon('MoveVertical', G['arrows-vertical']);
export const Palette = makeIcon('Palette', G['palette']);
export const Palmtree = makeIcon('Palmtree', G['beach']);
export const PanelLeftClose = makeIcon('PanelLeftClose', G['layout-sidebar-left-collapse']);
export const PanelRight = makeIcon('PanelRight', G['layout-sidebar-right']);
export const Paperclip = makeIcon('Paperclip', G['paperclip']);
export const PenLine = makeIcon('PenLine', G['pencil']);
export const PenSquare = makeIcon('PenSquare', G['edit']);
export const Pencil = makeIcon('Pencil', G['pencil']);
export const Phone = makeIcon('Phone', G['phone']);
export const Pin = makeIcon('Pin', G['pinned']);
export const PinOff = makeIcon('PinOff', G['pinned-off']);
export const Plus = makeIcon('Plus', G['plus']);
export const Puzzle = makeIcon('Puzzle', G['puzzle']);
export const QrCode = makeIcon('QrCode', G['qrcode']);
export const Quote = makeIcon('Quote', G['quote']);
export const Redo2 = makeIcon('Redo2', G['arrow-forward']);
export const RefreshCw = makeIcon('RefreshCw', G['refresh']);
export const RemoveFormatting = makeIcon('RemoveFormatting', G['clear-formatting']);
export const Repeat = makeIcon('Repeat', G['repeat']);
export const Reply = makeIcon('Reply', G['arrow-back-up']);
export const ReplyAll = makeIcon('ReplyAll', G['arrow-back-up-double']);
export const RotateCcw = makeIcon('RotateCcw', G['rotate']);
export const Rows2 = makeIcon('Rows2', G['layout-rows']);
export const Rss = makeIcon('Rss', G['rss']);
export const Save = makeIcon('Save', G['device-floppy']);
export const ScanSearch = makeIcon('ScanSearch', G['zoom-scan']);
export const Scale = makeIcon('Scale', G['scale']);
export const Search = makeIcon('Search', G['search']);
export const Send = makeIcon('Send', G['send']);
export const Server = makeIcon('Server', G['server']);
export const Settings = makeIcon('Settings', G['settings']);
export const Share2 = makeIcon('Share2', G['share-2']);
export const Shield = makeIcon('Shield', G['shield']);
export const ShieldAlert = makeIcon('ShieldAlert', G['shield-exclamation']);
export const ShieldCheck = makeIcon('ShieldCheck', G['shield-check']);
export const ShieldQuestion = makeIcon('ShieldQuestion', G['shield-question']);
export const Shuffle = makeIcon('Shuffle', G['arrows-shuffle']);
export const Smartphone = makeIcon('Smartphone', G['device-mobile']);
export const Square = makeIcon('Square', G['square']);
export const SquareCheck = makeIcon('SquareCheck', G['square-check']);
export const SquarePen = makeIcon('SquarePen', G['edit']);
export const Star = makeIcon('Star', G['star']);
export const StickyNote = makeIcon('StickyNote', G['note']);
export const Strikethrough = makeIcon('Strikethrough', G['strikethrough']);
export const Sun = makeIcon('Sun', G['sun']);
export const Table = makeIcon('Table', G['table']);
export const Tag = makeIcon('Tag', G['tag']);
export const Tags = makeIcon('Tags', G['tags']);
export const Terminal = makeIcon('Terminal', G['terminal']);
export const Trash = makeIcon('Trash', G['trash']);
export const Trash2 = makeIcon('Trash2', G['trash']);
export const Type = makeIcon('Type', G['typography']);
export const Underline = makeIcon('Underline', G['underline']);
export const Undo2 = makeIcon('Undo2', G['arrow-back']);
export const Upload = makeIcon('Upload', G['upload']);
export const User = makeIcon('User', G['user']);
export const UserCircle = makeIcon('UserCircle', G['user-circle']);
export const UserMinus = makeIcon('UserMinus', G['user-minus']);
export const UserPen = makeIcon('UserPen', G['user-edit']);
export const UserPlus = makeIcon('UserPlus', G['user-plus']);
export const UserRound = makeIcon('UserRound', G['user']);
export const UserRoundPlus = makeIcon('UserRoundPlus', G['user-plus']);
export const Users = makeIcon('Users', G['users']);
export const Video = makeIcon('Video', G['video']);
export const X = makeIcon('X', G['x']);
export const XCircle = makeIcon('XCircle', G['circle-x']);
@@ -1,486 +0,0 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Linking, Alert, ActivityIndicator } from 'react-native';
import Constants from 'expo-constants';
import * as DocumentPicker from 'expo-document-picker';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { CloudDownload, ExternalLink } from 'lucide-react-native';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import Button from '../Button';
import Dialog from '../Dialog';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { ALL_DEBUG_CATEGORIES, useSettingsStore, type DebugCategory } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
import { useOutboxStore } from '../../stores/outbox-store';
import { useUpdatesStore } from '../../stores/updates-store';
import { runOfflineSync, formatBytes } from '../../lib/offline-sync';
import { clearCachedData } from '../../lib/clear-cached-data';
import { supportsSideloadUpdates } from '../../lib/platform-capabilities';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
const GIT_COMMIT = (Constants.expoConfig?.extra as { commit?: string } | undefined)?.commit ?? 'dev';
const APP_STORE_URL = 'https://github.com/bulwarkmail/native/releases';
export function AboutDataSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const resetToDefaults = useSettingsStore((s) => s.resetToDefaults);
const exportSettings = useSettingsStore((s) => s.exportSettings);
const importSettings = useSettingsStore((s) => s.importSettings);
const debugMode = useSettingsStore((s) => s.debugMode);
const debugCategories = useSettingsStore((s) => s.debugCategories);
const [confirmReset, setConfirmReset] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineDays = useSettingsStore((s) => s.offlineCacheDays);
const offlineMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const queuedChanges = useOutboxStore((s) => s.entries.length);
const flushOutbox = useOutboxStore((s) => s.flush);
const cacheCount = useOfflineCacheStore((s) => s.totalCount());
const cacheBytes = useOfflineCacheStore((s) => s.totalSize());
const cacheHydrated = useOfflineCacheStore((s) => s.hydrated);
const cacheHydrate = useOfflineCacheStore((s) => s.hydrate);
const sync = useOfflineCacheStore((s) => s.sync);
const clearAllCache = useOfflineCacheStore((s) => s.clearAll);
const syncBusy = sync.phase === 'scanning' || sync.phase === 'fetching';
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
const latestRelease = useUpdatesStore((s) => s.cachedLatest);
const updateAvailable = hasUpdate();
const updateSeverity = latestRelease?.severity ?? 'normal';
useEffect(() => {
if (!hydrated) void hydrate();
if (!cacheHydrated) void cacheHydrate();
}, [hydrated, hydrate, cacheHydrated, cacheHydrate]);
const dayOptions = [1, 3, 7, 14, 30, 90].map((d) => ({
value: String(d),
label: d === 1
? t('settings.offline.window_24h', 'Last 24 hours')
: t('settings.offline.window_days', 'Last {count} days', { count: d }),
}));
const maxMbOptions = [25, 50, 100, 250, 500].map((mb) => ({ value: String(mb), label: `${mb} MB` }));
const formatRelativeTime = (ms: number | undefined): string => {
if (!ms) return t('settings.offline.never', 'never');
const diff = Date.now() - ms;
if (diff < 60_000) return t('settings.offline.just_now', 'just now');
if (diff < 60 * 60_000) return t('settings.offline.minutes_ago', '{count} min ago', { count: Math.round(diff / 60_000) });
if (diff < 24 * 60 * 60_000) return t('settings.offline.hours_ago', '{count} h ago', { count: Math.round(diff / (60 * 60_000)) });
return t('settings.offline.days_ago', '{count} d ago', { count: Math.round(diff / (24 * 60 * 60_000)) });
};
const handleSyncNow = () => {
void runOfflineSync({ days: offlineDays, maxMB: offlineMaxMB });
// Also drain any queued offline changes while we're at it.
void flushOutbox();
};
const handleClearCache = () => {
Alert.alert(
t('settings.offline.clear_title', 'Clear offline mail'),
t(
'settings.offline.clear_message',
'Remove {count, plural, one {# cached message} other {# cached messages}} ({size})?',
{ count: cacheCount, size: formatBytes(cacheBytes) },
),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{ text: t('settings.offline.clear', 'Clear'), style: 'destructive', onPress: () => { void clearAllCache(); } },
],
);
};
const handleReset = () => {
setConfirmReset(false);
resetToDefaults();
Alert.alert(
t('settings.advanced.reset_settings.label', 'Reset Settings'),
t('settings.advanced.reset_settings.done', 'All settings have been restored to their defaults.'),
);
};
const handleExport = async () => {
try {
const json = exportSettings();
const file = new File(Paths.cache, `bulwark-settings-${new Date().toISOString().slice(0, 10)}.json`);
file.write(json);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(file.uri, {
mimeType: 'application/json',
dialogTitle: t('settings.advanced.export_settings.label', 'Export Settings'),
});
} else {
Alert.alert(
t('settings.advanced.export_settings.label', 'Export Settings'),
t('settings.advanced.export_settings.saved_to', 'Saved to {path}', { path: file.uri }),
);
}
} catch (err) {
Alert.alert(
t('settings.advanced.export_settings.label', 'Export Settings'),
err instanceof Error ? err.message : String(err),
);
}
};
const handleImport = async () => {
try {
const picked = await DocumentPicker.getDocumentAsync({
type: ['application/json', 'text/plain', '*/*'],
copyToCacheDirectory: true,
multiple: false,
});
if (picked.canceled || !picked.assets?.[0]) return;
const json = await new File(picked.assets[0].uri).text();
const ok = importSettings(json);
Alert.alert(
t('settings.advanced.import_settings.label', 'Import Settings'),
ok
? t('settings.import_success', 'Settings imported successfully')
: t('settings.import_error', 'Failed to import settings'),
);
} catch (err) {
Alert.alert(
t('settings.advanced.import_settings.label', 'Import Settings'),
err instanceof Error ? err.message : t('settings.import_error', 'Failed to import settings'),
);
}
};
const handleRefreshCache = async () => {
setRefreshing(true);
try {
await clearCachedData();
} catch (err) {
Alert.alert(
t('settings.advanced.refresh_cache.label', 'Refresh cached data'),
err instanceof Error ? err.message : String(err),
);
} finally {
setRefreshing(false);
}
};
return (
<View style={styles.container}>
<View style={styles.aboutBox}>
<View style={styles.aboutRow}>
<View style={styles.logo}>
<Text style={styles.logoText}>B</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.aboutTitle}>{t('settings.advanced.about.mobile_title', 'Bulwark Mobile')}</Text>
<Text style={styles.aboutVersion}>
v{APP_VERSION}{' '}
<Text style={styles.aboutCommit}>({GIT_COMMIT})</Text>
</Text>
{updateAvailable && latestRelease && (
<Pressable
onPress={() => void Linking.openURL(latestRelease.htmlUrl || APP_STORE_URL)}
accessibilityRole="link"
style={[styles.updatePill, updateSeverity !== 'normal' && styles.updatePillSecurity]}
>
<Text style={[styles.updatePillText, updateSeverity !== 'normal' && styles.updatePillTextSecurity]}>
{updateSeverity === 'security'
? t('settings.advanced.about.security_update', 'Security update: {version}', { version: latestRelease.tag })
: updateSeverity === 'deprecated'
? t('settings.advanced.about.deprecated_update', 'Update required: {version}', { version: latestRelease.tag })
: t('settings.advanced.about.update_available', 'Update: {version}', { version: latestRelease.tag })}
{supportsSideloadUpdates
? ''
: ` · ${t('settings.advanced.about.update_store_hint', 'available in TestFlight / App Store')}`}
</Text>
</Pressable>
)}
</View>
<Pressable
style={styles.ghLink}
accessibilityRole="link"
onPress={() => Linking.openURL('https://github.com/bulwarkmail/native')}
>
<Text style={styles.ghText}>GitHub</Text>
<ExternalLink size={12} color={c.mutedForeground} />
</Pressable>
</View>
</View>
<SettingsSection
title={t('settings.offline.title', 'Offline mail')}
description={t(
'settings.offline.description',
'Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand.',
)}
>
<SettingItem
label={t('settings.offline.enabled', 'Cache recent mail')}
description={t('settings.offline.enabled_desc', 'When on, the app keeps message bodies for the selected window on this device.')}
>
<ToggleSwitch
checked={offlineEnabled}
onChange={(v) => updateSetting('offlineCacheEnabled', v)}
/>
</SettingItem>
<SettingItem
label={t('settings.offline.window', 'Window')}
description={t('settings.offline.window_desc', 'How far back to cache, measured by message receipt date.')}
>
<Select
value={String(offlineDays)}
onChange={(v) => updateSetting('offlineCacheDays', Number(v))}
options={dayOptions}
/>
</SettingItem>
<SettingItem
label={t('settings.offline.max_size', 'Maximum size')}
description={t('settings.offline.max_size_desc', 'Oldest messages are removed when the cache grows past this.')}
>
<Select
value={String(offlineMaxMB)}
onChange={(v) => updateSetting('offlineCacheMaxMB', Number(v))}
options={maxMbOptions}
/>
</SettingItem>
<View style={styles.cacheStatsBox}>
<View style={styles.cacheStatsHeader}>
<CloudDownload size={16} color={c.textSecondary} />
<Text style={styles.cacheStatsTitle}>
{cacheCount === 0
? t('settings.offline.nothing_cached', 'Nothing cached yet')
: `${t('settings.offline.cached_count', '{count, plural, one {# message} other {# messages}}', { count: cacheCount })} • ${formatBytes(cacheBytes)}`}
</Text>
</View>
<Text style={styles.cacheStatsSub}>
{sync.phase === 'fetching'
? t('settings.offline.downloading', 'Downloading {completed}/{total}…', { completed: sync.completed, total: sync.total })
: sync.phase === 'scanning'
? t('settings.offline.scanning', 'Scanning recent mail…')
: sync.phase === 'error'
? t('settings.offline.last_sync_failed', 'Last sync failed: {message}', { message: sync.message ?? t('identities.validation_errors.unknown_error', 'Unknown error') })
: t('settings.offline.last_sync', 'Last sync {when}', { when: formatRelativeTime(sync.finishedAt) })}
</Text>
{queuedChanges > 0 && (
<Text style={styles.cacheStatsSub}>
{t('settings.offline.queued_changes', '{count, plural, one {# change} other {# changes}} waiting to sync', { count: queuedChanges })}
</Text>
)}
{syncBusy && sync.total > 0 && (
<View style={styles.progressTrack} accessibilityRole="progressbar">
<View
style={[
styles.progressFill,
{ width: `${Math.min(100, Math.round((sync.completed / sync.total) * 100))}%` },
]}
/>
</View>
)}
<View style={styles.cacheActions}>
<Button
variant="outline"
size="sm"
onPress={handleSyncNow}
disabled={!offlineEnabled || syncBusy}
icon={syncBusy ? <ActivityIndicator size="small" color={c.text} /> : undefined}
>
{syncBusy ? t('settings.offline.syncing', 'Syncing…') : t('settings.offline.sync_now', 'Sync now')}
</Button>
<Button
variant="outline"
size="sm"
onPress={handleClearCache}
disabled={cacheCount === 0 || syncBusy}
>
{t('settings.offline.clear_cache', 'Clear cache')}
</Button>
</View>
</View>
</SettingsSection>
<SettingsSection
title={t('settings.advanced.title', 'Advanced')}
description={t('settings.advanced.description', 'Advanced options and developer settings')}
>
<SettingItem
label={t('settings.advanced.debug_mode.label', 'Debug Mode')}
description={t('settings.advanced.debug_mode.description', 'Enable detailed logging for troubleshooting')}
>
<ToggleSwitch checked={debugMode} onChange={(v) => updateSetting('debugMode', v)} />
</SettingItem>
{debugMode && (
<View style={styles.debugCategoriesBox}>
<Text style={styles.debugCategoriesHint}>
{t(
'settings.advanced.debug_categories.description',
"Select which categories to log. Disable categories you don't need to reduce console noise.",
)}
</Text>
{ALL_DEBUG_CATEGORIES.map((cat: DebugCategory) => (
<SettingItem
key={cat}
label={t(`settings.advanced.debug_categories.${cat}`, cat)}
description={t(`settings.advanced.debug_categories.${cat}_description`, '')}
>
<ToggleSwitch
checked={debugCategories[cat] !== false}
onChange={(v) => updateSetting('debugCategories', { ...debugCategories, [cat]: v })}
/>
</SettingItem>
))}
</View>
)}
<SettingItem
label={t('settings.advanced.sender_favicons.label', 'Sender Favicons')}
description={t('settings.advanced.sender_favicons.description', 'Show website icons as profile pictures for business senders')}
>
<ToggleSwitch checked={senderFavicons} onChange={setSenderFaviconsStore} />
</SettingItem>
<SettingItem
label={t('settings.advanced.refresh_cache.label', 'Refresh cached data')}
description={t(
'settings.advanced.refresh_cache.description',
'Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.',
)}
>
<Button variant="outline" size="sm" onPress={() => void handleRefreshCache()} loading={refreshing}>
{t('settings.advanced.refresh_cache.button', 'Refresh')}
</Button>
</SettingItem>
<SettingItem
label={t('settings.advanced.export_settings.label', 'Export Settings')}
description={t('settings.advanced.export_settings.description', 'Download your settings as JSON')}
>
<Button variant="outline" size="sm" onPress={() => void handleExport()}>
{t('settings.advanced.export_settings.button', 'Export')}
</Button>
</SettingItem>
<SettingItem
label={t('settings.advanced.import_settings.label', 'Import Settings')}
description={t('settings.advanced.import_settings.description', 'Upload settings from JSON file')}
>
<Button variant="outline" size="sm" onPress={() => void handleImport()}>
{t('settings.advanced.import_settings.button', 'Import')}
</Button>
</SettingItem>
<SettingItem
label={t('settings.advanced.reset_settings.label', 'Reset Settings')}
description={t('settings.advanced.reset_settings.description', 'Restore all settings to default values')}
>
<Button variant="outline" size="sm" onPress={() => setConfirmReset(true)}>
{t('settings.advanced.reset_settings.button', 'Reset to Defaults')}
</Button>
</SettingItem>
</SettingsSection>
<Dialog
visible={confirmReset}
variant="destructive"
title={t('settings.advanced.reset_settings.label', 'Reset Settings')}
message={t('settings.reset_confirm', 'Are you sure you want to reset all settings to defaults?')}
confirmText={t('settings.advanced.reset_settings.button', 'Reset to Defaults')}
onCancel={() => setConfirmReset(false)}
onConfirm={handleReset}
/>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
aboutBox: {
padding: spacing.xl,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.card,
},
aboutRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.lg,
},
logo: {
width: 48,
height: 48,
borderRadius: radius.md,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
},
logoText: { fontSize: 24, fontWeight: '700', color: c.primaryForeground },
aboutTitle: { ...typography.bodyMedium, color: c.text },
aboutVersion: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
aboutCommit: { color: c.mutedForeground, opacity: 0.6 },
updatePill: {
alignSelf: 'flex-start',
marginTop: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
updatePillSecurity: { backgroundColor: c.errorBg },
updatePillText: { ...typography.caption, color: c.primary },
updatePillTextSecurity: { color: c.error },
ghLink: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
ghText: { ...typography.caption, color: c.mutedForeground },
debugCategoriesBox: {
marginStart: spacing.lg,
paddingStart: spacing.lg,
borderStartWidth: 2,
borderStartColor: c.muted,
gap: 4,
},
debugCategoriesHint: {
...typography.caption,
color: c.mutedForeground,
marginBottom: spacing.sm,
},
cacheStatsBox: {
marginTop: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.muted,
gap: spacing.sm,
},
cacheStatsHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
cacheStatsTitle: { ...typography.bodyMedium, color: c.text, flex: 1 },
cacheStatsSub: { ...typography.caption, color: c.mutedForeground },
progressTrack: {
height: 4,
borderRadius: radius.full,
backgroundColor: c.border,
overflow: 'hidden',
},
progressFill: { height: '100%', backgroundColor: c.primary },
cacheActions: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
});
}
+202
View File
@@ -0,0 +1,202 @@
import React, { useEffect } from 'react';
import { View, Text, Image, StyleSheet, Pressable, Linking, Platform } from 'react-native';
import Constants from 'expo-constants';
import { ExternalLink } from '../icons';
import { SettingsSection, SettingItem } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors, useResolvedTheme } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useAuthStore } from '../../stores/auth-store';
import { useAccountStore } from '../../stores/account-store';
import { useUpdatesStore } from '../../stores/updates-store';
import { supportsSideloadUpdates } from '../../lib/platform-capabilities';
import { buildIssueUrl, sourceCodeUrl, updateSourceUrl } from '../../lib/about-links';
const LOGO_WHITE = require('../../../assets/logos/Bulwark Logo White.png');
const LOGO_COLOR = require('../../../assets/logos/Bulwark Logo Color.png');
const extra = (Constants.expoConfig?.extra ?? {}) as { commit?: string; sourceCodeUrl?: string };
export const APP_NAME = Constants.expoConfig?.name ?? 'Bulwark';
export const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
export const GIT_COMMIT = extra.commit ?? 'dev';
const SOURCE_URL = sourceCodeUrl(extra.sourceCodeUrl);
function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
interface AboutSettingsProps {
/** Opens a page below About: Updates, the licence list, Privacy. */
onOpen?: (page: 'updates' | 'licences' | 'privacy') => void;
}
export function AboutSettings({ onOpen }: AboutSettingsProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const scheme = useResolvedTheme();
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
const authUsername = useAuthStore((s) => s.username);
const authServerUrl = useAuthStore((s) => s.serverUrl);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const account = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
const serverUrl = account?.serverUrl || authServerUrl || '';
const signedInAs = account?.email || account?.username || authUsername || '';
const hydrated = useUpdatesStore((s) => s.hydrated);
const hydrate = useUpdatesStore((s) => s.hydrate);
const checking = useUpdatesStore((s) => s.checking);
const checkNow = useUpdatesStore((s) => s.checkNow);
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
const latest = useUpdatesStore((s) => s.cachedLatest);
const updateAvailable = useUpdatesStore((s) => s.hasUpdate());
const severity = latest?.severity ?? 'normal';
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const updateLine = updateAvailable && latest
? severity === 'security'
? t('settings.advanced.about.security_update', 'Security update: {version}', { version: latest.tag })
: severity === 'deprecated'
? t('settings.advanced.about.deprecated_update', 'Update required: {version}', { version: latest.tag })
: t('settings.advanced.about.update_available', 'Update: {version}', { version: latest.tag })
: lastCheckedAt > 0
? t('settings.about.up_to_date', 'Up to date')
: null;
const openUpdate = () => {
if (supportsSideloadUpdates && onOpen) onOpen('updates');
else void Linking.openURL(updateSourceUrl(Platform.OS, latest?.htmlUrl));
};
const lastChecked = lastCheckedAt > 0
? t('settings.about.checked_at', 'Checked {when}', {
when: new Date(lastCheckedAt).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }),
})
: t('settings.about.never_checked', 'Not checked yet');
const reportProblem = () => {
const constants = Platform.constants as { Release?: string; Model?: string };
void Linking.openURL(buildIssueUrl(
{ version: APP_VERSION, commit: GIT_COMMIT },
{
os: Platform.OS,
osVersion: Platform.Version,
release: constants.Release,
model: constants.Model,
},
));
};
return (
<View style={styles.container}>
<View style={styles.card}>
<Image
source={scheme === 'dark' ? LOGO_WHITE : LOGO_COLOR}
style={styles.logo}
resizeMode="contain"
accessibilityIgnoresInvertColors
accessible={false}
/>
<View style={styles.cardText}>
<Text style={styles.appName}>{APP_NAME}</Text>
<Text style={styles.version}>v{APP_VERSION} ({GIT_COMMIT})</Text>
{updateLine && (
updateAvailable ? (
<Pressable onPress={openUpdate} accessibilityRole="link" hitSlop={{ top: 12, bottom: 12 }}>
<Text style={[styles.updateLine, severity === 'normal' ? styles.updateLineNew : styles.updateLineUrgent]}>
{updateLine}
</Text>
</Pressable>
) : (
<Text style={[styles.updateLine, styles.updateLineOk]}>{updateLine}</Text>
)
)}
</View>
<Pressable
onPress={() => void Linking.openURL(SOURCE_URL)}
accessibilityRole="link"
style={styles.sourceLink}
>
<Text style={styles.sourceText}>{t('settings.about.source_code', 'Source code')}</Text>
<ExternalLink size={14} color={c.mutedForeground} />
</Pressable>
</View>
<SettingsSection
title={t('settings.about.this_device', 'This device')}
description={t('settings.about.this_device_desc', 'What the app is connected to and how to get help.')}
>
<SettingItem
label={t('settings.about.server', 'Server')}
description={serverUrl
? t('settings.about.server_value', '{host} · signed in as {user}', { host: hostOf(serverUrl), user: signedInAs })
: undefined}
/>
{supportsSideloadUpdates && (
<SettingItem label={t('settings.about.updates', 'Updates')} description={lastChecked}>
<Button variant="outline" size="sm" loading={checking} onPress={() => void checkNow({ force: true })}>
{t('settings.about.check_now', 'Check now')}
</Button>
</SettingItem>
)}
<SettingItem
label={t('settings.about.licences', 'Open-source licences')}
description={t('settings.about.licences_desc', 'AGPL-3.0 for the app, and the libraries it uses')}
onPress={() => onOpen?.('licences')}
/>
<SettingItem
label={t('settings.about.privacy', 'Privacy')}
description={t('settings.about.privacy_desc', 'What stays on this phone and what goes to your server')}
onPress={() => onOpen?.('privacy')}
/>
<SettingItem
label={t('settings.about.report_problem', 'Report a problem')}
description={t('settings.about.report_problem_desc', 'Opens an issue on GitHub with the version filled in')}
onPress={reportProblem}
link="external"
noBorder
/>
</SettingsSection>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxl },
card: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.card,
},
logo: { width: 48, height: 48 },
cardText: { flex: 1, minWidth: 0, gap: 3 },
appName: { fontSize: 16, fontWeight: '600', lineHeight: 22, color: c.text },
version: { ...typography.caption, fontSize: 13, lineHeight: 18, color: c.mutedForeground },
updateLine: { ...typography.caption },
updateLineOk: { color: c.success },
updateLineNew: { color: c.textLink },
updateLineUrgent: { color: c.error },
sourceLink: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
minHeight: 48,
paddingHorizontal: 4,
},
sourceText: { fontSize: 13, color: c.mutedForeground },
});
}
@@ -4,7 +4,7 @@ import DateTimePicker, { type DateTimePickerEvent } from '@react-native-communit
import {
Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2,
Plus, Shield, Terminal, Check, ExternalLink, Calendar, X,
} from 'lucide-react-native';
} from '../icons';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Input from '../Input';
import Button from '../Button';
+9 -5
View File
@@ -2,10 +2,10 @@ import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Alert } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ArrowDown, ArrowUp, Check, ChevronRight, Plus, Star, Trash2, Users } from 'lucide-react-native';
import { ArrowDown, ArrowUp, Check, ChevronRight, Plus, Star, Trash2, Users } from '../icons';
import { SettingsSection, SettingItem } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAuthStore } from '../../stores/auth-store';
import { useAccountStore } from '../../stores/account-store';
@@ -52,7 +52,9 @@ export function AccountSettings() {
const username = account?.username || authUsername || undefined;
const serverUrl = account?.serverUrl || authServerUrl || '';
// Live client state, not a prop: OAuth/handoff accounts carry a bearer token.
const authMode: 'oauth' | 'basic' = jmapClient.usesBearerAuth ? 'oauth' : 'basic';
const authMode: 'oauth' | 'token' | 'basic' = jmapClient.usesPastedToken
? 'token'
: jmapClient.usesBearerAuth ? 'oauth' : 'basic';
const [quota, setQuota] = useState<MailQuota | null>(null);
useEffect(() => {
@@ -128,7 +130,9 @@ export function AccountSettings() {
<Text style={styles.value}>
{authMode === 'oauth'
? t('settings.account.auth_method_oauth', 'Single Sign-On (OAuth/OIDC)')
: t('settings.account.auth_method_basic', 'Password')}
: authMode === 'token'
? t('settings.account.auth_method_token', 'Access token')
: t('settings.account.auth_method_basic', 'Password')}
</Text>
</SettingItem>
@@ -341,7 +345,7 @@ function makeStyles(c: ThemePalette) {
rowError: { ...typography.caption, color: c.error },
defaultBadge: { ...typography.caption, color: c.primary, marginTop: 2 },
rowActions: { flexDirection: 'row', gap: 2 },
iconBtn: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
iconBtn: { width: componentSizes.buttonLg, height: componentSizes.buttonLg, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
addRow: { gap: spacing.sm, alignItems: 'flex-start' },
hint: { ...typography.caption, color: c.mutedForeground },
});
+11 -1
View File
@@ -93,6 +93,8 @@ export function AppearanceSettings() {
const setShowToolbarLabels = useSettingsStore((s) => s.setShowToolbarLabels);
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const setAnimationsEnabled = useSettingsStore((s) => s.setAnimationsEnabled);
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const setSenderFavicons = useSettingsStore((s) => s.setSenderFavicons);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
@@ -103,7 +105,7 @@ export function AppearanceSettings() {
return (
<SettingsSection
title={t('settings.appearance.title', "Appearance")}
description={t('settings.appearance.description', "Customize the look and feel of your webmail")}
description={t('settings.appearance.description_mobile', 'Customize how the app looks')}
>
<SettingItem label={t('settings.appearance.theme.label', "Theme")} description={t('settings.appearance.theme.description', "Choose your preferred color scheme")}>
<RadioGroup
@@ -152,6 +154,14 @@ export function AppearanceSettings() {
<SettingItem label={t('settings.appearance.animations.label', "Enable Animations")} description={t('settings.appearance.animations.description', "Show smooth transitions and effects")}>
<ToggleSwitch checked={animationsEnabled} onChange={setAnimationsEnabled} />
</SettingItem>
<SettingItem
label={t('settings.advanced.sender_favicons.label', 'Sender Favicons')}
description={t('settings.advanced.sender_favicons.description', 'Show website icons as profile pictures for business senders')}
noBorder
>
<ToggleSwitch checked={senderFavicons} onChange={setSenderFavicons} />
</SettingItem>
</SettingsSection>
);
}
+133 -114
View File
@@ -8,6 +8,9 @@ import {
} from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { AUTO_TIME_ZONE, getDeviceTimeZone, isValidTimeZone } from '../../lib/calendar-timezone';
import { deviceSyncAvailable } from '../../device-sync/app/available';
import { CALENDAR_AUTHORITY } from '../../device-sync/types';
import { DeviceSyncSection } from './device-sync/DeviceSyncSection';
// A compact list of IANA zones for the picker (#755). The device zone and
// any previously stored value are always offered too.
@@ -46,6 +49,9 @@ export function CalendarSettings() {
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
// Android with the native module only (#34).
const deviceSync = React.useMemo(() => deviceSyncAvailable(), []);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
@@ -64,130 +70,143 @@ export function CalendarSettings() {
}, [deviceZone, timeZone, t]);
return (
<SettingsSection title={t('calendar.settings.title', 'Calendar')}>
<SettingItem label={t('calendar.settings.default_view', 'Default view')}>
<Select
value={viewMode}
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
options={[
{ value: 'month', label: t('calendar.views.month', 'Month') },
{ value: 'week', label: t('calendar.views.week', 'Week') },
{ value: 'day', label: t('calendar.views.day', 'Day') },
{ value: 'agenda', label: t('calendar.views.agenda', 'Agenda') },
]}
/>
</SettingItem>
<>
<SettingsSection title={t('calendar.settings.title', 'Calendar')}>
<SettingItem label={t('calendar.settings.default_view', 'Default view')}>
<Select
value={viewMode}
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
options={[
{ value: 'month', label: t('calendar.views.month', 'Month') },
{ value: 'week', label: t('calendar.views.week', 'Week') },
{ value: 'day', label: t('calendar.views.day', 'Day') },
{ value: 'agenda', label: t('calendar.views.agenda', 'Agenda') },
]}
/>
</SettingItem>
<SettingItem label={t('calendar.settings.week_starts_on', 'Week starts on')}>
<Select
value={String(firstDay)}
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
options={[
{ value: '1', label: t('calendar.days.monday', 'Monday') },
{ value: '6', label: t('calendar.days.saturday', 'Saturday') },
{ value: '0', label: t('calendar.days.sunday', 'Sunday') },
]}
/>
</SettingItem>
<SettingItem label={t('calendar.settings.week_starts_on', 'Week starts on')}>
<Select
value={String(firstDay)}
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
options={[
{ value: '1', label: t('calendar.days.monday', 'Monday') },
{ value: '6', label: t('calendar.days.saturday', 'Saturday') },
{ value: '0', label: t('calendar.days.sunday', 'Sunday') },
]}
/>
</SettingItem>
<SettingItem label={t('calendar.settings.time_format', 'Time format')}>
<RadioGroup
value={timeFormat}
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
options={[
{ value: '12h', label: t('calendar.settings.time_format_12h', '12-hour') },
{ value: '24h', label: t('calendar.settings.time_format_24h', '24-hour') },
]}
/>
</SettingItem>
<SettingItem label={t('calendar.settings.time_format', 'Time format')}>
<RadioGroup
value={timeFormat}
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
options={[
{ value: '12h', label: t('calendar.settings.time_format_12h', '12-hour') },
{ value: '24h', label: t('calendar.settings.time_format_24h', '24-hour') },
]}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.time_zone', 'Time zone')}
description={t(
'calendar.settings.time_zone_desc',
'Zone used for new events and for interpreting the calendar. "Device" follows the phone.',
)}
>
<Select
value={timeZone || AUTO_TIME_ZONE}
onChange={(v) => update('calendarTimeZone', v)}
options={timeZoneOptions}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_time_in_month_view', 'Show time in month view')}
description={t(
'calendar.settings.show_time_in_month_view_desc',
'Display event times in the month calendar view. On small screens this shows full event entries instead of dots.',
)}
>
<ToggleSwitch
checked={showTimeInMonth}
onChange={(v) => update('calendarShowTimeInMonth', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_week_numbers', 'Show week numbers')}
description={t('calendar.settings.show_week_numbers_mobile_desc', 'Display week numbers in the month view.')}
>
<ToggleSwitch
checked={showWeekNumbers}
onChange={(v) => update('calendarShowWeekNumbers', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.calendar_free_scroll', 'Free scrolling')}
description={t(
'calendar.settings.calendar_free_scroll_desc',
'Scroll continuously through months, weeks and days instead of one period at a time',
)}
>
<ToggleSwitch
checked={freeScroll}
onChange={(v) => update('calendarFreeScroll', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
description={t(
'calendar.settings.show_birthday_calendar_desc',
'Show a virtual calendar with birthdays from your contacts',
)}
>
<ToggleSwitch
checked={birthdayCal}
onChange={(v) => update('showBirthdayCalendar', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
>
<ToggleSwitch
checked={tasksEnabled}
onChange={(v) => update('enableCalendarTasks', v)}
/>
</SettingItem>
{tasksEnabled && (
<SettingItem
label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
label={t('calendar.settings.time_zone', 'Time zone')}
description={t(
'calendar.settings.show_tasks_on_calendar_desc',
'Display task chips on the day and week calendar views',
'calendar.settings.time_zone_desc',
'Zone used for new events and for interpreting the calendar. "Device" follows the phone.',
)}
>
<Select
value={timeZone || AUTO_TIME_ZONE}
onChange={(v) => update('calendarTimeZone', v)}
options={timeZoneOptions}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_time_in_month_view', 'Show time in month view')}
description={t(
'calendar.settings.show_time_in_month_view_desc',
'Display event times in the month calendar view. On small screens this shows full event entries instead of dots.',
)}
>
<ToggleSwitch
checked={showTasksOnCal}
onChange={(v) => update('showTasksOnCalendar', v)}
checked={showTimeInMonth}
onChange={(v) => update('calendarShowTimeInMonth', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_week_numbers', 'Show week numbers')}
description={t('calendar.settings.show_week_numbers_mobile_desc', 'Display week numbers in the month view.')}
>
<ToggleSwitch
checked={showWeekNumbers}
onChange={(v) => update('calendarShowWeekNumbers', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.calendar_free_scroll', 'Free scrolling')}
description={t(
'calendar.settings.calendar_free_scroll_desc',
'Scroll continuously through months, weeks and days instead of one period at a time',
)}
>
<ToggleSwitch
checked={freeScroll}
onChange={(v) => update('calendarFreeScroll', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
description={t(
'calendar.settings.show_birthday_calendar_desc',
'Show a virtual calendar with birthdays from your contacts',
)}
>
<ToggleSwitch
checked={birthdayCal}
onChange={(v) => update('showBirthdayCalendar', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
>
<ToggleSwitch
checked={tasksEnabled}
onChange={(v) => update('enableCalendarTasks', v)}
/>
</SettingItem>
{tasksEnabled && (
<SettingItem
label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
description={t(
'calendar.settings.show_tasks_on_calendar_desc',
'Display task chips on the day and week calendar views',
)}
>
<ToggleSwitch
checked={showTasksOnCal}
onChange={(v) => update('showTasksOnCalendar', v)}
/>
</SettingItem>
)}
</SettingsSection>
{deviceSync && (
<DeviceSyncSection
authority={CALENDAR_AUTHORITY}
title={t('calendar.settings.device_sync_title', 'Sync to this device')}
description={t(
'calendar.settings.device_sync_desc',
'Show your calendars in the Calendar app and other apps on this device. Changes sync both ways.',
)}
/>
)}
</SettingsSection>
</>
);
}
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { X, Plus } from 'lucide-react-native';
import { X, Plus } from '../icons';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
+17 -1
View File
@@ -4,17 +4,20 @@ import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import {
Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser, Star,
} from 'lucide-react-native';
} from '../icons';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import Dialog from '../Dialog';
import { ContactImportSheet, AddressBookPickerSheet } from '../contacts';
import { DeviceSyncSection } from './device-sync/DeviceSyncSection';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store';
import { contactsToVCard } from '../../lib/vcard';
import { isGroup } from '../../lib/contact-utils';
import { inlineContactMediaForExport } from '../../lib/contact-media';
import { deviceSyncAvailable } from '../../device-sync/app/available';
import { CONTACTS_AUTHORITY } from '../../device-sync/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -54,6 +57,8 @@ export function ContactsSettings() {
const [newName, setNewName] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string; count: number } | null>(null);
// Android with the native module only (#34).
const deviceSync = React.useMemo(() => deviceSyncAvailable(), []);
const exportable = React.useMemo(() => contacts.filter((cc) => !isGroup(cc)), [contacts]);
const exportLabel =
@@ -358,6 +363,17 @@ export function ContactsSettings() {
</View>
</SettingsSection>
{deviceSync && (
<DeviceSyncSection
authority={CONTACTS_AUTHORITY}
title={t('settings.contacts.device_sync_title', 'Sync to this device')}
description={t(
'settings.contacts.device_sync_desc',
'Show your address books in the Contacts app and other apps on this device. Changes sync both ways.',
)}
/>
)}
<ContactImportSheet
visible={importOpen}
onClose={() => setImportOpen(false)}
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import {
View, Text, StyleSheet, Pressable, TextInput, FlatList, Modal, Alert,
} from 'react-native';
import { ChevronRight, X, Plus, Trash2, BookUser } from 'lucide-react-native';
import { ChevronRight, X, Plus, Trash2, BookUser } from '../icons';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -10,7 +10,9 @@ import { useColors } from '../../theme/colors';
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
import { useContactsStore } from '../../stores/contacts-store';
import { useHasContacts } from '../../lib/capabilities';
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
interface TrustedRow {
email: string;
@@ -49,9 +51,8 @@ export function ContentSendersSettings() {
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
// Sync stays off until the user turns it on below (null = never chosen):
// opening this pane must not change which senders the viewer trusts.
const syncEnabled = !!trustedSendersAddressBook && hasContacts;
// On unless the user turned it off below (null = never chosen).
const syncEnabled = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
useEffect(() => {
if (modalOpen && syncEnabled) void loadTrustedSendersBook();
@@ -179,7 +180,7 @@ export function ContentSendersSettings() {
description={t('settings.email_behavior.trusted_senders.use_address_book_description', 'Store trusted senders in a dedicated "Trusted Senders" address book so they sync across all your devices')}
>
<ToggleSwitch
checked={!!trustedSendersAddressBook}
checked={syncEnabled}
onChange={(enabled) => {
updateSetting('trustedSendersAddressBook', enabled);
if (enabled) void loadTrustedSendersBook();
@@ -189,14 +190,14 @@ export function ContentSendersSettings() {
)}
</SettingsSection>
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={modalOpen}
transparent
animationType="fade"
onRequestClose={() => setModalOpen(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
<Pressable style={styles.modalCard} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
<Pressable accessible={false} style={styles.modalCard} onPress={() => {}}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')}</Text>
<Pressable
@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { RotateCcw } from 'lucide-react-native';
import { RotateCcw } from '../icons';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import Input from '../Input';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -179,7 +179,7 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
gap: 2,
},
previewLabel: { fontSize: 11, fontWeight: '600', color: c.mutedForeground, textTransform: 'uppercase' },
previewLabel: { fontSize: 12, fontWeight: '500', color: c.mutedForeground },
previewValue: { ...typography.captionMedium, color: c.text },
tokenWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: spacing.md },
tokenPill: {
+1 -1
View File
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import {
Folder, FileText, FileCode, FileAudio, File, Image as ImageIcon,
} from 'lucide-react-native';
} from '../icons';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
+8 -7
View File
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'rea
import {
Plus, X, Code, AlertTriangle, Filter, RotateCcw, Lock,
ChevronUp, ChevronDown, Palmtree, ChevronRight,
} from 'lucide-react-native';
} from '../icons';
import { SettingsSection, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -21,6 +21,7 @@ import { FilterRuleModal } from '../filters/FilterRuleModal';
import { SieveEditorSheet } from '../filters/SieveEditorSheet';
import type { FilterRule } from '../../lib/sieve/types';
import { formatConditionValue, summarizeRule } from '../../lib/sieve/condition-value';
import Badge from '../Badge';
type Translate = (key: string, fallback?: string) => string;
@@ -366,7 +367,7 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
<View style={{ flex: 1, minWidth: 0 }}>
<View style={styles.ruleNameRow}>
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
<View style={styles.originBadge}><Text style={styles.originBadgeText}>{label}</Text></View>
<Badge>{label}</Badge>
</View>
{hasStructured ? (
expandedView ? (
@@ -512,11 +513,13 @@ function makeStyles(c: ThemePalette) {
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: 'rgba(252, 211, 77, 0.4)',
backgroundColor: 'rgba(120, 53, 15, 0.25)',
// Theme warning tokens: the fixed dark-amber values left light-yellow
// text on a near-white fill in the light theme.
borderColor: c.warning,
backgroundColor: c.warningBg,
alignItems: 'flex-start',
},
opaqueText: { ...typography.caption, color: '#fde68a' },
opaqueText: { ...typography.caption, color: c.text },
opaqueActions: { flexDirection: 'row', gap: spacing.lg, marginTop: spacing.sm, flexWrap: 'wrap', alignItems: 'center' },
resetConfirmRow: { flexDirection: 'row', gap: spacing.md, alignItems: 'center' },
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
@@ -569,8 +572,6 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
borderRadius: radius.xs,
},
originBadge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full, backgroundColor: c.muted },
originBadgeText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
reorderCol: { alignItems: 'center' },
reorderBtn: { padding: 2 },
+9 -13
View File
@@ -13,7 +13,7 @@ import {
import {
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive, Flag, Star, Mails,
StickyNote, Clock, AlarmClock, Users, Plus, Pencil, Trash2, X,
} from 'lucide-react-native';
} from '../icons';
import { SettingsSection, Select } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -25,6 +25,9 @@ import { useLocaleStore } from '../../stores/locale-store';
import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email';
import { jmapClient } from '../../api/jmap-client';
import type { Mailbox } from '../../api/types';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import Badge from '../Badge';
const ROLE_ICON: Record<string, any> = {
inbox: Inbox, drafts: FileText, sent: Send, trash: Trash,
@@ -63,6 +66,8 @@ function pathOf(all: Mailbox[], mb: Mailbox, t: (k: string, f?: string) => strin
}
export function FolderSettings() {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -271,11 +276,7 @@ export function FolderSettings() {
<View style={styles.folderLeft}>
<Icon size={16} color={mb.role ? c.primary : c.mutedForeground} />
<Text style={styles.folderName} numberOfLines={1}>{localizeMailboxName(mb.role, mb.name, t)}</Text>
{mb.role && (
<View style={styles.rolePill}>
<Text style={styles.rolePillText}>{mb.role}</Text>
</View>
)}
{mb.role && <Badge>{mb.role}</Badge>}
</View>
<View style={styles.folderRight}>
{mb.unreadEmails > 0 && (
@@ -297,8 +298,8 @@ export function FolderSettings() {
)}
</SettingsSection>
<Modal visible={!!editor} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editor} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
@@ -412,11 +413,6 @@ function makeStyles(c: ThemePalette) {
folderRowPressed: { backgroundColor: c.muted },
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
folderName: { ...typography.body, color: c.text, flexShrink: 1 },
rolePill: {
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
rolePillText: { fontSize: 10, fontWeight: '500', color: c.primary },
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
unreadBadge: {
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
+9 -11
View File
@@ -10,7 +10,7 @@ import {
TextInput,
View,
} from 'react-native';
import { Plus, Star, Trash2, X } from 'lucide-react-native';
import { Plus, Star, Trash2, X } from '../icons';
import { SettingsSection } from './settings-section';
import Button from '../Button';
import { typography, spacing, radius, type ThemePalette } from '../../theme/tokens';
@@ -27,6 +27,9 @@ import { useLocaleStore } from '../../stores/locale-store';
import { isValidEmail, parseRecipientList, formatRecipient } from '../../lib/recipients';
import { sanitizeDisplayName } from '../../lib/rfc5322-mailbox';
import { sanitizeSignatureHtml } from '../../lib/signature-utils';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
import Badge from '../Badge';
type DraftIdentity = {
id: string;
@@ -92,6 +95,8 @@ function parseAddressList(value: string): { list: EmailAddress[]; invalid: strin
}
export function IdentitySettings() {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const identities = useSettingsStore((s) => s.identities);
@@ -276,9 +281,7 @@ export function IdentitySettings() {
</Pressable>
)}
{!identity.mayDelete ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>{t('settings.identities.primary', "primary")}</Text>
</View>
<Badge>{t('settings.identities.primary', "primary")}</Badge>
) : (
<Pressable
onPress={() => confirmDelete(identity)}
@@ -298,8 +301,8 @@ export function IdentitySettings() {
</Pressable>
))}
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
@@ -419,11 +422,6 @@ function makeStyles(c: ThemePalette) {
identityName: { ...typography.bodyMedium, color: c.text },
identityEmail: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
identityDelete: { padding: 6 },
badge: {
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
badgeText: { fontSize: 10, fontWeight: '500', color: c.primary },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
modalSheet: {
+14 -4
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput, Alert, ActivityIndicator } from 'react-native';
import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from 'lucide-react-native';
import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from '../icons';
import { SettingsSection } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -45,8 +45,17 @@ export function KeywordSettings() {
}
};
const deleteKeyword = (id: string) => {
removeKeyword(id);
// Deleting a tag takes it off every message's tag list on this device; ask
// first, as the reset next to it does.
const deleteKeyword = (id: string, label: string) => {
Alert.alert(
t('settings.keywords.delete_confirm_title', 'Delete tag "{label}"?', { label }),
undefined,
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{ text: t('settings.keywords.delete', 'Delete tag'), style: 'destructive', onPress: () => removeKeyword(id) },
],
);
};
// One stray tap used to wipe a carefully built tag list (webmail removed
@@ -119,7 +128,7 @@ export function KeywordSettings() {
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit', "Edit tag")}>
<Pencil size={14} color={c.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete', "Delete tag")}>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id, kw.label)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete', "Delete tag")}>
<Trash2 size={14} color={c.mutedForeground} />
</Pressable>
</View>
@@ -308,6 +317,7 @@ function makeStyles(c: ThemePalette) {
},
bottomActions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
paddingTop: spacing.sm,
},

Some files were not shown because too many files have changed in this diff Show More