Compare commits

..
6 Commits
14 changed files with 664 additions and 229 deletions
+27 -8
View File
@@ -15,7 +15,7 @@ import {
getInitialNotificationTap,
getStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotifications,
teardownPushNotificationsForAccount,
type NotificationTapPayload,
} from './src/lib/push-notifications';
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
@@ -51,8 +51,21 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabsParamList>();
const navigationRef = createNavigationContainerRef<RootStackParamList>();
function navigateToNotificationTap(payload: NotificationTapPayload): void {
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
if (!navigationRef.isReady()) return;
// The notification carries the account it was generated for. If the user
// has since switched to a different account (or had a different one active
// when the notification arrived), opening EmailThread under the active
// account would fetch the email from the wrong server and fail.
const auth = useAuthStore.getState();
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
const account = useAccountStore.getState().getAccountById(payload.accountId);
if (!account) return; // account was logged out — nothing safe to open.
await auth.switchAccount(payload.accountId);
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
}
navigationRef.navigate('EmailThread', {
emailId: payload.emailId,
threadId: payload.threadId,
@@ -261,11 +274,11 @@ export default function App() {
void (async () => {
const initial = await getInitialNotificationTap();
if (cancelled || !initial) return;
navigateToNotificationTap(initial);
await navigateToNotificationTap(initial);
})();
const unsubscribe = addNotificationTapListener((payload) => {
navigateToNotificationTap(payload);
void navigateToNotificationTap(payload);
});
return () => {
@@ -276,18 +289,24 @@ export default function App() {
// Re-register the device with the configured relay once authenticated,
// and whenever the FCM token rotates. Honours the user's notification
// preference - flipping it off tears down the active subscription so the
// FirebaseMessagingService stops receiving pings for this device.
// preference - flipping it off tears down THIS account's subscription so
// notifications stop arriving for it. Other logged-in accounts keep their
// setups intact.
const emailNotificationsEnabled = useSettingsStore(
(s) => s.emailNotificationsEnabled,
);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
React.useEffect(() => {
if (!isAuthenticated || !client) return;
let cancelled = false;
const doSetup = async () => {
if (!emailNotificationsEnabled) {
await teardownPushNotifications().catch(() => undefined);
if (activeAccountId) {
await teardownPushNotificationsForAccount(activeAccountId).catch(
() => undefined,
);
}
return;
}
const relayBaseUrl = await getStoredRelayBaseUrl();
@@ -315,7 +334,7 @@ export default function App() {
cancelled = true;
unsubscribe();
};
}, [client, isAuthenticated, emailNotificationsEnabled]);
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
React.useEffect(() => {
if (!isAuthenticated || !client) {
+1 -1
View File
@@ -1 +1 @@
0.1.27
0.1.30
@@ -65,6 +65,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
val emailId = options.getString("emailId")
val threadId = options.getString("threadId")
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
// Bitmap fetch + draw off the bridge thread so the caller doesn't
// block waiting for the favicon request.
@@ -73,7 +74,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
?: makeLetterAvatar(initials, bgColorHex)
postNotification(
notificationId, title, body, largeIcon, bgColorHex,
emailId, threadId, subject,
emailId, threadId, subject, accountId,
)
promise.resolve(null)
}
@@ -94,6 +95,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
emailId: String?,
threadId: String?,
subject: String?,
accountId: String?,
) {
val ctx = reactApplicationContext
val intent = Intent(ctx, MainActivity::class.java).apply {
@@ -101,6 +103,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
}
val pending = PendingIntent.getActivity(
ctx,
@@ -23,6 +23,11 @@ object NotificationTapStore {
// our notification builder writes — preventing other apps from
// navigating us to a forged email/thread.
private val ID_PATTERN = Regex("^[A-Za-z0-9_-]{1,128}$")
// accountId is `username@host` (see generateAccountId), so it needs `.`,
// `@`, `+` etc. — cap length and restrict charset to what that helper can
// emit. accountId is optional: notifications created before this field
// existed still navigate (using the active account as a fallback).
private val ACCOUNT_ID_PATTERN = Regex("^[A-Za-z0-9._+@-]{1,256}$")
fun captureFromIntent(intent: Intent?): TapPayload? {
val extras = intent?.extras ?: return null
@@ -34,12 +39,14 @@ object NotificationTapStore {
// cap its length so a hostile launcher can't ship a 1MB string into
// the navigation payload.
val subject = extras.getString(EXTRA_SUBJECT)?.take(512)
val payload = TapPayload(emailId, threadId, subject)
val accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) }
val payload = TapPayload(emailId, threadId, subject, accountId)
pending = payload
// Clear so a subsequent activity lifecycle event doesn't replay this.
extras.remove(EXTRA_EMAIL_ID)
extras.remove(EXTRA_THREAD_ID)
extras.remove(EXTRA_SUBJECT)
extras.remove(EXTRA_ACCOUNT_ID)
return payload
}
@@ -47,15 +54,18 @@ object NotificationTapStore {
val emailId: String,
val threadId: String,
val subject: String?,
val accountId: String?,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
putString("emailId", emailId)
putString("threadId", threadId)
if (subject != null) putString("subject", subject)
if (accountId != null) putString("accountId", accountId)
}
}
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
const val EXTRA_SUBJECT = "bulwark.notification.subject"
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.27",
"version": "0.1.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.27",
"version": "0.1.30",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.27",
"version": "0.1.30",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+51
View File
@@ -61,6 +61,57 @@ export async function uploadBlob(
throw new Error('Upload succeeded but response did not include a blobId');
}
// Direct in-memory upload. The on-disk variant goes through expo-file-system's
// `new File(uri).bytes()`, which is overkill when we already have the bytes
// (e.g. the empty-blob trick used to create directories on Stalwart).
export async function uploadBytes(
bytes: Uint8Array,
type: string,
): Promise<{ blobId: string; size: number; type: string }> {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(jmapClient.accountId),
);
const response = await secureFetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': type || 'application/octet-stream',
Authorization: jmapClient.authHeader,
},
body: bytes.buffer as ArrayBuffer,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
const raw = (await response.json()) as Record<string, unknown>;
const direct = raw as { blobId?: string; type?: string; size?: number };
if (typeof direct.blobId === 'string') {
return {
blobId: direct.blobId,
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
};
}
const nested = raw[jmapClient.accountId] as
| { blobId?: string; type?: string; size?: number }
| undefined;
if (nested?.blobId) {
return {
blobId: nested.blobId,
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
};
}
throw new Error('Upload succeeded but response did not include a blobId');
}
export function getDownloadUrl(
blobId: string,
name?: string,
+100 -14
View File
@@ -1,7 +1,7 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { FileNode } from './types';
import { getDownloadUrl, uploadBlob } from './blob';
import { getDownloadUrl, uploadBlob, uploadBytes } from './blob';
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
@@ -16,17 +16,50 @@ const DIRECTORY_TYPES = new Set([
]);
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
return DIRECTORY_TYPES.has(node.type) || node.type.includes('directory');
const t = node.type;
if (!t) return false;
return DIRECTORY_TYPES.has(t) || t.includes('directory');
}
// Stalwart's FileNode/get does not support a parentId filter — fetching with
// `ids: null` returns the whole tree, which the caller filters client-side.
// Mirrors the webmail file-store; keeping the API uniform avoids a divergence
// later when we add other JMAP servers.
// Stalwart rejects `/` in FileNode names, so the webmail (and now this app)
// uses U+2215 DIVISION SLASH as the path separator inside the flat `name`
// field. Hierarchy is encoded into the name — there is no `parentId` nesting.
// e.g. a file inside the `Documents` folder is stored with
// `name = "Documents∕report.pdf"`. The display name shown to the user
// strips the prefix.
export const PATH_SEP = '∕';
// Path is a list of folder display names from root. [] is root,
// ['Documents'] is inside Documents, ['Documents','sub'] is inside the
// `sub` folder under Documents.
export function pathToPrefix(path: readonly string[]): string {
if (path.length === 0) return '';
return path.join(PATH_SEP) + PATH_SEP;
}
// True when `node` is a direct child (not a descendant) of `prefix`.
export function isDirectChildOfPrefix(node: Pick<FileNode, 'name'>, prefix: string): boolean {
if (!node.name) return false;
if (prefix === '') return !node.name.includes(PATH_SEP);
if (!node.name.startsWith(prefix)) return false;
const rest = node.name.slice(prefix.length);
return rest.length > 0 && !rest.includes(PATH_SEP);
}
export function nodeDisplayName(node: Pick<FileNode, 'name'>, prefix: string): string {
return prefix && node.name.startsWith(prefix) ? node.name.slice(prefix.length) : node.name;
}
// Stalwart's FileNode/get does not support a parentId filter and some builds
// omit `type` from the default property set. We fetch the whole tree and
// filter client-side; properties are listed explicitly so `isDirectory()`
// always has a value to look at.
const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated'];
export async function getAllFileNodes(): Promise<FileNode[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['FileNode/get', { accountId, ids: null }, '0']],
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
USING,
);
return (res.methodResponses[0][1].list ?? []) as FileNode[];
@@ -52,10 +85,15 @@ export async function createFileNode(
}
export async function createFolder(
name: string,
parentId: string | null,
displayName: string,
currentPath: readonly string[],
): Promise<FileNode> {
return createFileNode({ name, parentId, type: 'd', blobId: null, size: 0 });
// Stalwart's FileNode/set rejects directory creates without a blobId
// ("Missing blob id"), so we upload an empty blob first and reference it.
// Mirrors the webmail's createFileDirectory.
const blob = await uploadBytes(new Uint8Array(0), 'application/x-directory');
const fullName = pathToPrefix(currentPath) + displayName;
return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 });
}
export async function updateFileNode(
@@ -71,6 +109,33 @@ export async function updateFileNode(
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
}
// Rename a node and, if it's a folder, cascade the rename to every descendant
// so the encoded path stays consistent. The webmail does this same dance.
export async function renameFileNode(
node: FileNode,
newDisplayName: string,
currentPath: readonly string[],
allNodes: readonly FileNode[],
): Promise<void> {
const prefix = pathToPrefix(currentPath);
const oldServerName = node.name;
const newServerName = prefix + newDisplayName;
if (oldServerName === newServerName) return;
await updateFileNode(node.id, { name: newServerName });
if (isDirectory(node)) {
const oldFolderPrefix = oldServerName + PATH_SEP;
const newFolderPrefix = newServerName + PATH_SEP;
for (const n of allNodes) {
if (n.id === node.id) continue;
if (n.name.startsWith(oldFolderPrefix)) {
await updateFileNode(n.id, { name: newFolderPrefix + n.name.slice(oldFolderPrefix.length) });
}
}
}
}
export async function deleteFileNodes(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
@@ -80,6 +145,27 @@ export async function deleteFileNodes(ids: string[]): Promise<void> {
);
}
// Delete `targets` and every descendant. Stalwart doesn't enforce parent
// references, so we collect descendants by name-prefix and destroy them all
// in one call. Without this, deleting a folder leaves its files orphaned and
// still discoverable at the old encoded path.
export async function deleteWithDescendants(
targets: readonly FileNode[],
allNodes: readonly FileNode[],
): Promise<void> {
const ids = new Set<string>();
for (const node of targets) {
ids.add(node.id);
if (isDirectory(node)) {
const folderPrefix = node.name + PATH_SEP;
for (const n of allNodes) {
if (n.name.startsWith(folderPrefix)) ids.add(n.id);
}
}
}
await deleteFileNodes(Array.from(ids));
}
export function getFileNodeDownloadUrl(node: FileNode): string {
if (!node.blobId) throw new Error('Folders cannot be downloaded');
return getDownloadUrl(node.blobId, node.name, node.type);
@@ -87,14 +173,14 @@ export function getFileNodeDownloadUrl(node: FileNode): string {
export async function uploadFileNode(
uri: string,
name: string,
displayName: string,
mimeType: string,
parentId: string | null,
currentPath: readonly string[],
): Promise<FileNode> {
const blob = await uploadBlob(uri, mimeType);
const fullName = pathToPrefix(currentPath) + displayName;
return createFileNode({
name,
parentId,
name: fullName,
type: blob.type || mimeType || 'application/octet-stream',
blobId: blob.blobId,
size: blob.size,
@@ -17,9 +17,10 @@ import { useSettingsStore, type NotificationSound } from '../../stores/settings-
import {
DEFAULT_RELAY_BASE_URL,
getStoredRelayBaseUrl,
readPushAccountIds,
setStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotifications,
teardownPushNotificationsForAccount,
} from '../../lib/push-notifications';
const SOUNDS: { value: NotificationSound; label: string }[] = [
@@ -40,6 +41,7 @@ export function NotificationSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const username = useAuthStore((s) => s.username);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
@@ -106,10 +108,20 @@ export function NotificationSettings() {
setConfirmDisable(false);
setPushStatus({ kind: 'busy', message: 'Disabling…' });
try {
await teardownPushNotifications();
await setStoredRelayBaseUrl(null);
setRelayUrl(DEFAULT_RELAY_BASE_URL);
setHasSaved(false);
if (activeAccountId) {
await teardownPushNotificationsForAccount(activeAccountId);
}
// The relay base URL is a device-wide setting shared with any other
// accounts that are still using push. Only clear it (and reset the UI
// to "not configured") when no accounts are using push any more —
// otherwise the remaining accounts would lose their relay URL and
// their next setup attempt would no-op.
const remaining = await readPushAccountIds();
if (remaining.length === 0) {
await setStoredRelayBaseUrl(null);
setRelayUrl(DEFAULT_RELAY_BASE_URL);
setHasSaved(false);
}
setPushStatus({ kind: 'idle' });
} catch (error) {
setPushStatus({
+132 -48
View File
@@ -8,13 +8,22 @@ import {
getFaviconDomain,
getFaviconUrl,
} from './avatar-utils';
import {
deviceClientIdKey,
lastNotifiedKey,
migrateLegacyPushKeys,
readPushAccountIds,
} from './push-notifications';
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
// Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't
// import the Zustand store (would pull in React) so we read AsyncStorage
// directly. Keep this in sync if the store key ever changes.
const SETTINGS_STORAGE_KEY = 'webmail:settings:v1';
// Mirrors the persist name in `stores/account-store.ts`. We use it to find
// the user's active account at the end of the headless task so we can leave
// the jmapClient singleton in a state consistent with what the UI expects
// when the app resumes.
const ACCOUNT_REGISTRY_KEY = 'account-registry';
interface PushPersistedSettings {
emailNotificationsEnabled?: boolean;
@@ -41,76 +50,151 @@ interface ShowNotificationOptions {
emailId: string;
threadId: string;
subject?: string;
accountId: string;
}
interface BulwarkFcmNative {
showNotification(opts: ShowNotificationOptions): Promise<void>;
}
// The relay forwards each JMAP push to FCM tagged with the deviceClientId it
// received the push on (the URL slot from createPushSubscription). The exact
// key the relay uses isn't part of any client-controlled contract, so rather
// than guessing a field name we scan all string values in the data payload
// and look for one that matches a deviceClientId we've registered locally.
// Returns null if nothing matches (e.g. legacy relay payload format) so the
// caller falls back to iterating every known account.
async function identifyAccountFromFcmData(
data: unknown,
accountIds: string[],
): Promise<string | null> {
if (!data || typeof data !== 'object') return null;
const reverseMap = new Map<string, string>();
for (const accountId of accountIds) {
const dcid = await AsyncStorage.getItem(deviceClientIdKey(accountId));
if (dcid) reverseMap.set(dcid, accountId);
}
if (reverseMap.size === 0) return null;
for (const value of Object.values(data as Record<string, unknown>)) {
if (typeof value === 'string') {
const match = reverseMap.get(value);
if (match) return match;
}
}
return null;
}
// Read the persisted active account directly from AsyncStorage (Zustand's
// persist middleware stores the JSON-serialised state under its `name`).
async function readActiveAccountId(): Promise<string | null> {
try {
const raw = await AsyncStorage.getItem(ACCOUNT_REGISTRY_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { state?: { activeAccountId?: string | null } };
return parsed.state?.activeAccountId ?? null;
} catch {
return null;
}
}
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a
// short-lived headless JS runtime - keep it fast, catch all errors, and always
// resolve so the native service can release its wake lock.
export async function pushBackgroundTask(_data: unknown): Promise<void> {
export async function pushBackgroundTask(data: unknown): Promise<void> {
let activeAccountId: string | null = null;
try {
if (!(await emailNotificationsAllowed())) return;
const accountId = await AsyncStorage.getItem(PUSH_ACCOUNT_ID_KEY);
if (!accountId) return;
await migrateLegacyPushKeys();
const loaded = await jmapClient.loadAccount(accountId);
if (!loaded) return;
const accountIds = await readPushAccountIds();
if (accountIds.length === 0) return;
const mailboxes = await getMailboxes();
const inbox = mailboxes.find((m) => m.role === 'inbox');
if (!inbox) return;
activeAccountId = await readActiveAccountId();
const { ids } = await queryEmails(inbox.id, {
filter: { notKeyword: '$seen' },
limit: 1,
});
if (ids.length === 0) return;
const matched = await identifyAccountFromFcmData(data, accountIds);
const accountsToCheck = matched ? [matched] : accountIds;
const emailId = ids[0];
const previouslyNotified = await AsyncStorage.getItem(LAST_NOTIFIED_EMAIL_ID_KEY);
if (previouslyNotified === emailId) return;
const [email] = await getEmails([emailId]);
if (!email) return;
const from = email.from?.[0];
const name = from?.name ?? '';
const address = from?.email ?? '';
const title = name || address || 'New mail';
const body = email.subject || '(no subject)';
const initials = getEmailInitials(name, address);
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
const faviconDomain = getFaviconDomain(address);
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
if (!native?.showNotification) return;
await native.showNotification({
notificationId: `mail:${emailId}`,
title,
body,
initials,
bgColorHex,
iconUrl,
emailId,
threadId: email.threadId,
subject: email.subject ?? undefined,
});
await AsyncStorage.setItem(LAST_NOTIFIED_EMAIL_ID_KEY, emailId);
for (const accountId of accountsToCheck) {
try {
await processAccountForPush(accountId);
} catch (err) {
console.warn(
'[push] background check failed for account',
accountId,
err instanceof Error ? err.message : err,
);
}
}
} catch (error) {
console.warn(
'[push] background task failed:',
error instanceof Error ? error.message : error,
);
} finally {
// Leave the singleton bound to the user's active account so the UI sees
// a consistent jmapClient state when the app resumes. Without this, the
// last account processed above would remain loaded and the UI's cached
// email-store data would not match what the next JMAP request returns.
if (activeAccountId) {
await jmapClient.loadAccount(activeAccountId).catch(() => undefined);
}
}
}
async function processAccountForPush(accountId: string): Promise<void> {
const loaded = await jmapClient.loadAccount(accountId);
if (!loaded) return;
const mailboxes = await getMailboxes();
const inbox = mailboxes.find((m) => m.role === 'inbox');
if (!inbox) return;
const { ids } = await queryEmails(inbox.id, {
filter: { notKeyword: '$seen' },
limit: 1,
});
if (ids.length === 0) return;
const emailId = ids[0];
const lastKey = lastNotifiedKey(accountId);
const previouslyNotified = await AsyncStorage.getItem(lastKey);
if (previouslyNotified === emailId) return;
const [email] = await getEmails([emailId]);
if (!email) return;
const from = email.from?.[0];
const name = from?.name ?? '';
const address = from?.email ?? '';
const title = name || address || 'New mail';
const body = email.subject || '(no subject)';
const initials = getEmailInitials(name, address);
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
const faviconDomain = getFaviconDomain(address);
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
if (!native?.showNotification) return;
await native.showNotification({
notificationId: `mail:${emailId}`,
title,
body,
initials,
bgColorHex,
iconUrl,
emailId,
threadId: email.threadId,
subject: email.subject ?? undefined,
accountId,
});
await AsyncStorage.setItem(lastKey, emailId);
}
function hslToHex(hsl: string): string {
const match = hsl.match(/hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)/);
if (!match) return '#2563eb';
+205 -43
View File
@@ -11,12 +11,88 @@ import { jmapClient } from '../api/jmap-client';
import { generateAccountId } from './account-utils';
// Persist identifiers across launches so we reuse the same JMAP subscription
// after app restarts. deviceClientId also routes to the relay slot.
const DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
const SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
// after app restarts. Each account gets its own deviceClientId so the hosted
// relay can distinguish per-account pushes via the URL slot it forwards.
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1';
const DEVICE_CLIENT_ID_PREFIX = 'push:deviceClientId:v2:';
const SUBSCRIPTION_ID_PREFIX = 'push:subscriptionId:v2:';
export const LAST_NOTIFIED_EMAIL_ID_PREFIX = 'push:lastNotifiedEmailId:v2:';
// Legacy single-account keys (pre-multi-account). Migrated lazily on the next
// setupPushNotifications / pushBackgroundTask call, then deleted.
const LEGACY_DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
const LEGACY_SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
const LEGACY_PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
export function deviceClientIdKey(accountId: string): string {
return DEVICE_CLIENT_ID_PREFIX + accountId;
}
function subscriptionIdKey(accountId: string): string {
return SUBSCRIPTION_ID_PREFIX + accountId;
}
export function lastNotifiedKey(accountId: string): string {
return LAST_NOTIFIED_EMAIL_ID_PREFIX + accountId;
}
export async function readPushAccountIds(): Promise<string[]> {
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((s): s is string => typeof s === 'string');
} catch {
return [];
}
}
async function writePushAccountIds(ids: string[]): Promise<void> {
const deduped = Array.from(new Set(ids));
if (deduped.length === 0) {
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
} else {
await AsyncStorage.setItem(PUSH_ACCOUNT_IDS_KEY, JSON.stringify(deduped));
}
}
// One-shot migration from the pre-multi-account schema. If the legacy
// PUSH_ACCOUNT_ID_KEY exists, treat that account as the only pre-existing
// setup: reuse the legacy deviceClientId and JMAP subscription id under
// the new per-account keys so the user doesn't lose push on upgrade.
export async function migrateLegacyPushKeys(): Promise<void> {
const legacyAccountId = await AsyncStorage.getItem(LEGACY_PUSH_ACCOUNT_ID_KEY);
if (!legacyAccountId) return;
const legacyDcid = await AsyncStorage.getItem(LEGACY_DEVICE_CLIENT_ID_KEY);
const legacySubId = await AsyncStorage.getItem(LEGACY_SUBSCRIPTION_ID_KEY);
const legacyLastId = await AsyncStorage.getItem(LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY);
if (legacyDcid) {
await AsyncStorage.setItem(deviceClientIdKey(legacyAccountId), legacyDcid);
}
if (legacySubId) {
await AsyncStorage.setItem(subscriptionIdKey(legacyAccountId), legacySubId);
}
if (legacyLastId) {
await AsyncStorage.setItem(lastNotifiedKey(legacyAccountId), legacyLastId);
}
const ids = await readPushAccountIds();
if (!ids.includes(legacyAccountId)) {
await writePushAccountIds([...ids, legacyAccountId]);
}
await AsyncStorage.multiRemove([
LEGACY_PUSH_ACCOUNT_ID_KEY,
LEGACY_DEVICE_CLIENT_ID_KEY,
LEGACY_SUBSCRIPTION_ID_KEY,
LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY,
]);
}
// Hosted relay so users don't need to run their own Firebase project. The
// relay only ever sees FCM tokens + JMAP state-id hashes - no mail content.
@@ -72,11 +148,12 @@ function randomClientId(): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
async function getOrCreateDeviceClientId(): Promise<string> {
const existing = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
async function getOrCreateDeviceClientId(accountId: string): Promise<string> {
const key = deviceClientIdKey(accountId);
const existing = await AsyncStorage.getItem(key);
if (existing) return existing;
const next = randomClientId();
await AsyncStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
await AsyncStorage.setItem(key, next);
return next;
}
@@ -184,20 +261,24 @@ export async function setupPushNotifications(
const fcmToken = await native.getToken();
if (!fcmToken) throw new Error('FCM token unavailable');
const deviceClientId = await getOrCreateDeviceClientId();
await setStoredRelayBaseUrl(relayBaseUrl);
// Persist the account id so the background push task can reload credentials
// from SecureStore without the main app being running.
// setupPushNotifications operates on the currently-loaded jmapClient. We
// need its username/serverUrl up-front so we can key per-account state.
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
if (username && serverUrl) {
await AsyncStorage.setItem(
PUSH_ACCOUNT_ID_KEY,
generateAccountId(username, serverUrl),
);
if (!username || !serverUrl) {
throw new Error('No account loaded - cannot set up push');
}
const accountId = generateAccountId(username, serverUrl);
await migrateLegacyPushKeys();
const deviceClientId = await getOrCreateDeviceClientId(accountId);
await setStoredRelayBaseUrl(relayBaseUrl);
// Register this account's device-client-id with the relay. Multiple
// accounts on the same device end up as separate registrations sharing
// one fcmToken - the relay forwards each push individually so the headless
// task can identify the source account via the FCM data payload.
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
@@ -208,26 +289,31 @@ export async function setupPushNotifications(
// Reuse the previous JMAP subscription when the server still has it, but
// push the expiry forward so it doesn't time out before the next app start.
const existingSubs = await listPushSubscriptions().catch(() => []);
const storedServerId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
const subKey = subscriptionIdKey(accountId);
const storedServerId = await AsyncStorage.getItem(subKey);
if (storedServerId) {
const match = existingSubs.find((s) => s.id === storedServerId);
if (match) {
const refreshed = await refreshSubscriptionExpires(match);
if (refreshed) {
await addPushAccountId(accountId);
return { subscriptionId: storedServerId, verified: true };
}
// Server rejected the refresh (likely the subscription was already
// deleted server-side) - drop the stale id and recreate below.
await destroyPushSubscription(storedServerId).catch(() => undefined);
}
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
await AsyncStorage.removeItem(subKey);
}
// Reap any leftover Stalwart subscriptions still bound to this device.
// These pile up when a previous enable attempt failed mid-flow (verify
// race, network blip, app killed). Stalwart per-account rate-limits
// PushVerification posts, so leaving stragglers around blocks the new
// one's verification - the symptom is a confusing "code does not match".
// Reap any leftover Stalwart subscriptions still bound to this account's
// deviceClientId. These pile up when a previous enable attempt failed
// mid-flow (verify race, network blip, app killed). Stalwart per-account
// rate-limits PushVerification posts, so leaving stragglers around blocks
// the new one's verification - the symptom is a confusing "code does not
// match". Only reap matches for THIS account's deviceClientId; other
// accounts' subscriptions on this server (rare but possible if the same
// JMAP server backs multiple accounts) must be left alone.
const stragglers = existingSubs.filter(
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
);
@@ -245,11 +331,19 @@ export async function setupPushNotifications(
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
await verifyPushSubscription(serverAssignedId, verificationCode);
await AsyncStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
await AsyncStorage.setItem(subKey, serverAssignedId);
await addPushAccountId(accountId);
return { subscriptionId: serverAssignedId, verified: true };
}
async function addPushAccountId(accountId: string): Promise<void> {
const ids = await readPushAccountIds();
if (!ids.includes(accountId)) {
await writePushAccountIds([...ids, accountId]);
}
}
// Push the subscription's expires forward when it's getting close to the
// server's ceiling. Returns false if the server rejects the update, which the
// caller treats as "recreate".
@@ -273,28 +367,92 @@ async function refreshSubscriptionExpires(
}
}
async function deregisterFromRelay(
relayBaseUrl: string,
deviceClientId: string,
): Promise<void> {
await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
{ method: 'DELETE' },
).catch(() => undefined);
}
/**
* Tear down push on logout / disable. Removes the server-side JMAP
* subscription, tells the relay to drop the mapping, and clears the local
* FCM token. Never throws - callers treat teardown as best effort.
* Tear down push for a single account. Destroys that account's JMAP
* subscription (assumes the jmapClient is currently authenticated to that
* account; the active-account logout flow guarantees this) and tells the
* relay to drop its mapping. Other accounts' push setups are untouched.
*
* If no accounts have push left after removal, also deletes the FCM token
* so the device stops receiving FCM messages entirely. Never throws -
* callers treat teardown as best effort.
*/
export async function teardownPushNotificationsForAccount(
accountId: string,
): Promise<void> {
await migrateLegacyPushKeys();
const subKey = subscriptionIdKey(accountId);
const storedSubId = await AsyncStorage.getItem(subKey);
const dcidKey = deviceClientIdKey(accountId);
const storedDcid = await AsyncStorage.getItem(dcidKey);
const relayBaseUrl = await getStoredRelayBaseUrl();
if (storedSubId) {
await destroyPushSubscription(storedSubId).catch(() => undefined);
}
if (relayBaseUrl && storedDcid) {
await deregisterFromRelay(relayBaseUrl, storedDcid);
}
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
const remaining = (await readPushAccountIds()).filter((id) => id !== accountId);
await writePushAccountIds(remaining);
// If this was the last account with push, kill the FCM token so the device
// truly goes silent. Otherwise the token stays alive so the remaining
// accounts keep receiving pushes.
if (remaining.length === 0) {
const native = getNative();
if (native) {
await native.deleteToken().catch(() => undefined);
}
}
}
/**
* Tear down push for ALL accounts on this device. Used by the logout-all
* flow; best-effort because we typically aren't authenticated to every
* account's JMAP server at the moment we need to call destroy on it. The
* FCM token is always deleted so no push gets through regardless.
*/
export async function teardownPushNotifications(): Promise<void> {
const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
const relayBaseUrl = await getStoredRelayBaseUrl();
const deviceClientId = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
await migrateLegacyPushKeys();
if (storedId) {
await destroyPushSubscription(storedId).catch(() => undefined);
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
const accountIds = await readPushAccountIds();
const relayBaseUrl = await getStoredRelayBaseUrl();
for (const accountId of accountIds) {
const subKey = subscriptionIdKey(accountId);
const dcidKey = deviceClientIdKey(accountId);
const storedSubId = await AsyncStorage.getItem(subKey);
const storedDcid = await AsyncStorage.getItem(dcidKey);
if (storedSubId) {
// Will only succeed if the jmapClient happens to be authenticated to
// this account right now. We don't switch the client to attempt each
// one; the subscription will expire server-side instead (90-day TTL).
await destroyPushSubscription(storedSubId).catch(() => undefined);
}
if (relayBaseUrl && storedDcid) {
await deregisterFromRelay(relayBaseUrl, storedDcid);
}
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
}
if (relayBaseUrl && deviceClientId) {
await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
{ method: 'DELETE' },
).catch(() => undefined);
}
await AsyncStorage.removeItem(PUSH_ACCOUNT_ID_KEY);
await AsyncStorage.removeItem(LAST_NOTIFIED_EMAIL_ID_KEY);
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
const native = getNative();
if (native) {
await native.deleteToken().catch(() => undefined);
@@ -327,6 +485,10 @@ export interface NotificationTapPayload {
emailId: string;
threadId: string;
subject?: string;
// Identifies which logged-in account the notification was generated for.
// Optional for back-compat: older notifications already on the system tray
// won't carry this and will fall back to the active account on tap.
accountId?: string;
}
// Returns - and clears - any pending "notification tap" that launched the app
+100 -101
View File
@@ -18,6 +18,7 @@ import {
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
MoreVertical, Pencil, Share2, Trash2, Upload, X, Download,
} from 'lucide-react-native';
// expo-document-picker is loaded lazily on first upload. Its native module
// is registered at app launch via Expo autolinking; on builds that predate
// the dep being added, requiring it at import time crashes the whole bundle
@@ -37,7 +38,8 @@ function loadDocumentPicker(): DocumentPickerModule | null {
}
import {
createFolder, deleteFileNodes, getAllFileNodes, isDirectory, updateFileNode,
createFolder, deleteWithDescendants, getAllFileNodes, isDirectChildOfPrefix,
isDirectory, nodeDisplayName, pathToPrefix, PATH_SEP, renameFileNode,
uploadFileNode,
} from '../api/files';
import { downloadAttachment, shareAttachment } from '../lib/email-export';
@@ -47,13 +49,12 @@ import { useColors } from '../theme/colors';
import { useSettingsStore, type FilesViewMode } from '../stores/settings-store';
import Dialog from '../components/Dialog';
interface FolderFrame {
id: string | null;
name: string;
// A file row carries the resolved display name alongside the original
// server-side name (the path-encoded `node.name` field).
interface FileRow extends FileNode {
displayName: string;
}
const ROOT_FRAME: FolderFrame = { id: null, name: 'Files' };
function pickFileIcon(name: string) {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return FileImage;
@@ -77,10 +78,6 @@ function fileIconColor(name: string, c: ThemePalette): string {
return c.textMuted;
}
function isHidden(node: FileNode): boolean {
return node.name.startsWith('.');
}
function formatFileSize(bytes?: number): string {
if (bytes == null) return '';
if (bytes < 1024) return `${bytes} B`;
@@ -111,7 +108,7 @@ export default function FilesScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [stack, setStack] = useState<FolderFrame[]>([ROOT_FRAME]);
const [path, setPath] = useState<string[]>([]);
const [allNodes, setAllNodes] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@@ -121,12 +118,12 @@ export default function FilesScreen() {
const [busyId, setBusyId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<{ ids: string[] } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null);
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [renameTarget, setRenameTarget] = useState<FileNode | null>(null);
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
const [renameValue, setRenameValue] = useState('');
const [actionsTarget, setActionsTarget] = useState<FileNode | null>(null);
const [actionsTarget, setActionsTarget] = useState<FileRow | null>(null);
const showIcons = useSettingsStore((s) => s.filesShowIcons);
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
@@ -139,8 +136,9 @@ export default function FilesScreen() {
const [viewOverride, setViewOverride] = useState<FilesViewMode | null>(null);
const viewMode: FilesViewMode = viewOverride ?? defaultViewMode;
const current = stack[stack.length - 1];
const selectionMode = selection.size > 0;
const prefix = useMemo(() => pathToPrefix(path), [path]);
const headerName = path.length === 0 ? 'Files' : path[path.length - 1];
const loadFiles = useCallback(
async (mode: 'initial' | 'refresh' = 'initial') => {
@@ -164,26 +162,31 @@ export default function FilesScreen() {
void loadFiles();
}, [loadFiles]);
// If we navigated into a folder that has since been deleted, walk back up
// until we land on a still-existing one (or root).
// If we navigated into a folder that no longer exists (it was deleted, or
// we never had it), walk back up until we land on one that does. The check
// looks for a directory node whose serverName matches the encoded path.
useEffect(() => {
if (allNodes.length === 0) return;
setStack((prev) => {
const ids = new Set(allNodes.map((n) => n.id));
let cut = prev.length;
for (let i = prev.length - 1; i > 0; i--) {
const id = prev[i].id;
if (id != null && !ids.has(id)) cut = i;
setPath((prev) => {
let next = prev;
while (next.length > 0) {
const parentPrefix = pathToPrefix(next.slice(0, -1));
const expectedName = parentPrefix + next[next.length - 1];
const exists = allNodes.some((n) => n.name === expectedName && isDirectory(n));
if (exists) break;
next = next.slice(0, -1);
}
return cut === prev.length ? prev : prev.slice(0, cut);
return next.length === prev.length ? prev : next;
});
}, [allNodes]);
const visibleFiles = useMemo(() => {
let out = allNodes.filter((n) => (n.parentId ?? null) === current.id);
if (!showHiddenFiles) out = out.filter((f) => !isHidden(f));
const visibleFiles = useMemo<FileRow[]>(() => {
const rows = allNodes
.filter((n) => isDirectChildOfPrefix(n, prefix))
.map<FileRow>((n) => ({ ...n, displayName: nodeDisplayName(n, prefix) }))
.filter((r) => showHiddenFiles || !r.displayName.startsWith('.'));
const dir = sortDir === 'desc' ? -1 : 1;
return [...out].sort((a, b) => {
return rows.sort((a, b) => {
const aDir = isDirectory(a) ? 0 : 1;
const bDir = isDirectory(b) ? 0 : 1;
if (aDir !== bDir) return aDir - bDir;
@@ -197,10 +200,10 @@ export default function FilesScreen() {
}
case 'name':
default:
return a.name.localeCompare(b.name) * dir;
return a.displayName.localeCompare(b.displayName) * dir;
}
});
}, [allNodes, current.id, showHiddenFiles, sortKey, sortDir]);
}, [allNodes, prefix, showHiddenFiles, sortKey, sortDir]);
const clearSelection = useCallback(() => setSelection(new Set()), []);
@@ -213,11 +216,11 @@ export default function FilesScreen() {
});
}, []);
const previewFile = useCallback(async (node: FileNode) => {
if (!node.blobId) return;
setBusyId(node.id);
const previewFile = useCallback(async (row: FileRow) => {
if (!row.blobId) return;
setBusyId(row.id);
try {
await shareAttachment(node.blobId, node.name, node.type);
await shareAttachment(row.blobId, row.displayName, row.type);
} catch (e) {
Alert.alert('Preview failed', e instanceof Error ? e.message : String(e));
} finally {
@@ -225,11 +228,11 @@ export default function FilesScreen() {
}
}, []);
const downloadFile = useCallback(async (node: FileNode) => {
if (!node.blobId) return;
setBusyId(node.id);
const downloadFile = useCallback(async (row: FileRow) => {
if (!row.blobId) return;
setBusyId(row.id);
try {
await downloadAttachment(node.blobId, node.name, node.type);
await downloadAttachment(row.blobId, row.displayName, row.type);
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
} finally {
@@ -238,26 +241,26 @@ export default function FilesScreen() {
}, []);
const handleRowPress = useCallback(
(node: FileNode) => {
(row: FileRow) => {
if (selectionMode) {
toggleSelect(node.id);
toggleSelect(row.id);
return;
}
if (isDirectory(node)) {
setStack((s) => [...s, { id: node.id, name: node.name }]);
if (isDirectory(row)) {
setPath((p) => [...p, row.displayName]);
return;
}
void previewFile(node);
void previewFile(row);
},
[selectionMode, toggleSelect, previewFile],
);
const handleRowLongPress = useCallback((node: FileNode) => {
toggleSelect(node.id);
const handleRowLongPress = useCallback((row: FileRow) => {
toggleSelect(row.id);
}, [toggleSelect]);
const goBack = useCallback(() => {
setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
setPath((p) => (p.length > 0 ? p.slice(0, -1) : p));
clearSelection();
}, [clearSelection]);
@@ -274,33 +277,41 @@ export default function FilesScreen() {
const submitNewFolder = useCallback(async () => {
const name = newFolderName.trim();
if (!name) return;
if (name.includes(PATH_SEP) || name.includes('/')) {
Alert.alert('Invalid name', 'Folder names cannot contain "/".');
return;
}
setNewFolderOpen(false);
setNewFolderName('');
try {
await createFolder(name, current.id);
await createFolder(name, path);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Create folder failed', e instanceof Error ? e.message : String(e));
}
}, [newFolderName, current.id, loadFiles]);
}, [newFolderName, path, loadFiles]);
const submitRename = useCallback(async () => {
if (!renameTarget) return;
const name = renameValue.trim();
if (!name || name === renameTarget.name) {
if (!name || name === renameTarget.displayName) {
setRenameTarget(null);
return;
}
if (name.includes(PATH_SEP) || name.includes('/')) {
Alert.alert('Invalid name', 'Names cannot contain "/".');
return;
}
const target = renameTarget;
setRenameTarget(null);
setRenameValue('');
try {
await updateFileNode(target.id, { name });
await renameFileNode(target, name, path, allNodes);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Rename failed', e instanceof Error ? e.message : String(e));
}
}, [renameTarget, renameValue, loadFiles]);
}, [renameTarget, renameValue, path, allNodes, loadFiles]);
const startUpload = useCallback(async () => {
if (uploading) return;
@@ -330,7 +341,7 @@ export default function FilesScreen() {
asset.uri,
asset.name,
asset.mimeType || 'application/octet-stream',
current.id,
path,
);
await loadFiles('refresh');
} catch (e) {
@@ -338,33 +349,20 @@ export default function FilesScreen() {
} finally {
setUploading(false);
}
}, [uploading, current.id, loadFiles]);
}, [uploading, path, loadFiles]);
const requestDelete = useCallback((ids: string[]) => {
if (ids.length === 0) return;
setConfirmDelete({ ids });
const requestDelete = useCallback((rows: FileRow[]) => {
if (rows.length === 0) return;
setConfirmDelete({ rows });
}, []);
const performDelete = useCallback(async () => {
if (!confirmDelete) return;
// Cascade: if the user deletes a folder, also delete every descendant
// we know about. Stalwart deletes folders with content silently otherwise.
const targetIds = new Set(confirmDelete.ids);
let frontier = [...confirmDelete.ids];
while (frontier.length > 0) {
const next: string[] = [];
for (const node of allNodes) {
if (node.parentId && frontier.includes(node.parentId) && !targetIds.has(node.id)) {
targetIds.add(node.id);
next.push(node.id);
}
}
frontier = next;
}
const targets = confirmDelete.rows;
setConfirmDelete(null);
clearSelection();
try {
await deleteFileNodes(Array.from(targetIds));
await deleteWithDescendants(targets, allNodes);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Delete failed', e instanceof Error ? e.message : String(e));
@@ -372,9 +370,10 @@ export default function FilesScreen() {
}, [confirmDelete, allNodes, clearSelection, loadFiles]);
const renderHeader = () => {
const canBack = stack.length > 1;
const canBack = path.length > 0;
const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon;
if (selectionMode) {
const selectedRows = visibleFiles.filter((r) => selection.has(r.id));
return (
<View style={styles.header}>
<View style={styles.headerRow}>
@@ -384,7 +383,7 @@ export default function FilesScreen() {
<Text style={styles.title}>{selection.size} selected</Text>
<View style={styles.headerActions}>
<Pressable
onPress={() => requestDelete(Array.from(selection))}
onPress={() => requestDelete(selectedRows)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="Delete selected"
@@ -406,7 +405,7 @@ export default function FilesScreen() {
</Pressable>
) : null}
<Text style={styles.title} numberOfLines={1}>
{current.name}
{headerName}
</Text>
</View>
<View style={styles.headerActions}>
@@ -443,19 +442,19 @@ export default function FilesScreen() {
</View>
{canBack ? (
<Text style={styles.breadcrumb} numberOfLines={1}>
{stack.map((f) => f.name).join(' / ')}
{['Files', ...path].join(' / ')}
</Text>
) : null}
</View>
);
};
const renderRow = (item: FileNode) => {
const renderRow = (item: FileRow) => {
const isDir = isDirectory(item);
const Icon = isDir ? Folder : pickFileIcon(item.name);
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
const selected = selection.has(item.id);
const busy = busyId === item.id;
return (
@@ -472,7 +471,7 @@ export default function FilesScreen() {
{showIcons && <Icon size={20} color={tint} />}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
{item.name}
{item.displayName}
</Text>
<View style={styles.fileMetaRow}>
{item.size != null && !isDir ? (
@@ -501,12 +500,12 @@ export default function FilesScreen() {
);
};
const renderGridCell = (item: FileNode) => {
const renderGridCell = (item: FileRow) => {
const isDir = isDirectory(item);
const Icon = isDir ? Folder : pickFileIcon(item.name);
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
const selected = selection.has(item.id);
return (
<Pressable
@@ -525,7 +524,7 @@ export default function FilesScreen() {
<View style={{ width: 36, height: 36 }} />
)}
<Text style={styles.gridName} numberOfLines={2}>
{item.name}
{item.displayName}
</Text>
{item.size != null && !isDir ? (
<Text style={styles.gridMeta} numberOfLines={1}>
@@ -560,7 +559,7 @@ export default function FilesScreen() {
<View style={styles.center}>
<HardDrive size={48} color={c.textMuted} />
<Text style={styles.emptyText}>
{stack.length > 1 ? 'This folder is empty' : 'No files yet'}
{path.length > 0 ? 'This folder is empty' : 'No files yet'}
</Text>
</View>
);
@@ -629,30 +628,30 @@ export default function FilesScreen() {
<ActionsSheet
target={actionsTarget}
onClose={() => setActionsTarget(null)}
onPreview={(n) => {
onPreview={(r) => {
setActionsTarget(null);
void previewFile(n);
void previewFile(r);
}}
onDownload={(n) => {
onDownload={(r) => {
setActionsTarget(null);
void downloadFile(n);
void downloadFile(r);
}}
onRename={(n) => {
onRename={(r) => {
setActionsTarget(null);
setRenameTarget(n);
setRenameValue(n.name);
setRenameTarget(r);
setRenameValue(r.displayName);
}}
onDelete={(n) => {
onDelete={(r) => {
setActionsTarget(null);
requestDelete([n.id]);
requestDelete([r]);
}}
/>
<Dialog
visible={confirmDelete != null}
title={
confirmDelete && confirmDelete.ids.length > 1
? `Delete ${confirmDelete.ids.length} items?`
confirmDelete && confirmDelete.rows.length > 1
? `Delete ${confirmDelete.rows.length} items?`
: 'Delete this item?'
}
message="Folders are deleted along with everything inside them. This can't be undone."
@@ -722,12 +721,12 @@ function PromptModal(props: PromptModalProps) {
}
interface ActionsSheetProps {
target: FileNode | null;
target: FileRow | null;
onClose: () => void;
onPreview: (n: FileNode) => void;
onDownload: (n: FileNode) => void;
onRename: (n: FileNode) => void;
onDelete: (n: FileNode) => void;
onPreview: (r: FileRow) => void;
onDownload: (r: FileRow) => void;
onRename: (r: FileRow) => void;
onDelete: (r: FileRow) => void;
}
function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDelete }: ActionsSheetProps) {
@@ -742,7 +741,7 @@ function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDele
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{target.name}
{target.displayName}
</Text>
{!isDir ? (
<Pressable style={styles.action} onPress={() => onPreview(target)}>
+1
View File
@@ -25,6 +25,7 @@ vi.mock('../../api/jmap-client', () => ({
vi.mock('../../lib/push-notifications', () => ({
teardownPushNotifications: vi.fn(async () => undefined),
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
}));
import { jmapClient } from '../../api/jmap-client';
+12 -4
View File
@@ -6,7 +6,10 @@ import { useEmailStore } from './email-store';
import { useContactsStore } from './contacts-store';
import { useCalendarStore } from './calendar-store';
import { generateAccountId } from '../lib/account-utils';
import { teardownPushNotifications } from '../lib/push-notifications';
import {
teardownPushNotifications,
teardownPushNotificationsForAccount,
} from '../lib/push-notifications';
// Persist middleware hydrates asynchronously on cold start. Without this
// guard, restoreSession() can read the account-store before AsyncStorage has
@@ -143,9 +146,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const accountStore = useAccountStore.getState();
const currentId = get().activeAccountId;
// Best-effort: revoke the JMAP PushSubscription and drop the relay
// mapping before we lose credentials. Do not abort logout on failure.
await teardownPushNotifications().catch(() => undefined);
// Best-effort: revoke this account's JMAP PushSubscription and drop its
// relay mapping before we lose credentials. Other logged-in accounts'
// push setups remain untouched. Do not abort logout on failure.
if (currentId) {
await teardownPushNotificationsForAccount(currentId).catch(() => undefined);
} else {
await teardownPushNotifications().catch(() => undefined);
}
// Clear credentials for this account first
if (currentId) {