From 7a15c0c6ac3acefac32239e078ae0723a8e67313 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:10:32 +0100 Subject: [PATCH 01/14] fix: list contacts and files only for shared accounts that support them (bulwarkmail/webmail#1239) --- src/api/__tests__/contacts.test.ts | 10 ++++++++++ src/api/__tests__/files.test.ts | 29 +++++++++++++++++++++++++---- src/api/contacts.ts | 14 ++++++++------ src/api/files.ts | 24 +++++++++++------------- 4 files changed, 54 insertions(+), 23 deletions(-) diff --git a/src/api/__tests__/contacts.test.ts b/src/api/__tests__/contacts.test.ts index c919cf4a..c812c3c7 100644 --- a/src/api/__tests__/contacts.test.ts +++ b/src/api/__tests__/contacts.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const session = { + capabilities: { 'urn:ietf:params:jmap:contacts': {} } as Record, primaryAccounts: {} as Record, accounts: {} as Record }>, }; @@ -60,6 +61,15 @@ describe('contacts operations', () => { }; expect(getContactCapableAccountIds()).toEqual(['acc-1', 'acc-team', 'acc-ct']); }); + + it('leaves out a shared account that lists its capabilities without contacts (bulwarkmail/webmail#1239)', () => { + session.accounts = { + 'acc-1': { name: 'me', isPersonal: true }, + 'acc-mail': { name: 'Mail only', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } }, + 'acc-team': { name: 'Team', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:contacts': {} } }, + }; + expect(getContactCapableAccountIds()).toEqual(['acc-1', 'acc-team']); + }); }); describe('getAddressBooks', () => { diff --git a/src/api/__tests__/files.test.ts b/src/api/__tests__/files.test.ts index 5bcad0d8..99f0e0ec 100644 --- a/src/api/__tests__/files.test.ts +++ b/src/api/__tests__/files.test.ts @@ -60,8 +60,12 @@ const mockMaxObjectsInGet = jmapClient.getMaxObjectsInGet as ReturnType; const mockMaxObjectsInSet = jmapClient.getMaxObjectsInSet as ReturnType; +// A session advertises FileNode unless the test says otherwise. function setSession(session: unknown) { - (jmapClient as { currentSession: unknown }).currentSession = session; + (jmapClient as { currentSession: unknown }).currentSession = + session && typeof session === 'object' + ? { capabilities: { [CAPABILITIES.FILES]: {} }, ...session } + : session; } beforeEach(() => { @@ -268,12 +272,15 @@ describe('getAllFileNodesAcrossAccounts', () => { expect(child.parentId).toBe('acc-2:s1'); }); - it('skips personal accounts without the filenode capability', async () => { + it('skips accounts that list their capabilities without filenode (bulwarkmail/webmail#1239)', async () => { setSession({ + capabilities: { [CAPABILITIES.FILES]: {}, [CAPABILITIES.MAIL]: {} }, primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' }, accounts: { 'acc-1': { name: 'me@example.com', isPersonal: true }, - 'acc-3': { name: 'other personal', isPersonal: true }, + 'acc-3': { name: 'other personal', isPersonal: true, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, + // A shared account used to count as capable whatever it listed. + 'acc-4': { name: 'Team', isPersonal: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, }, }); mockRequest.mockResolvedValue({ @@ -1177,7 +1184,10 @@ describe('FileNode/set refused by the server', () => { describe('accountSupportsFiles (#563)', () => { const caps = { [CAPABILITIES.FILES]: {} }; it('requires the account capability on personal accounts', () => { - expect(accountSupportsFiles({ name: 'me', isPersonal: true, isReadOnly: false }, caps)).toBe(false); + expect(accountSupportsFiles( + { name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, + caps, + )).toBe(false); expect(accountSupportsFiles( { name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.FILES]: {} } }, caps, @@ -1189,6 +1199,17 @@ describe('accountSupportsFiles (#563)', () => { expect(accountSupportsFiles({ name: 'grp', isPersonal: false, isReadOnly: false }, {})).toBe(false); expect(accountSupportsFiles(undefined, caps)).toBe(false); }); + + it('leaves out a shared account that lists its capabilities without filenode (bulwarkmail/webmail#1239)', () => { + expect(accountSupportsFiles( + { name: 'grp', isPersonal: false, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, + caps, + )).toBe(false); + expect(accountSupportsFiles( + { name: 'grp', isPersonal: false, isReadOnly: false, accountCapabilities: {} }, + caps, + )).toBe(true); + }); }); describe('supportsSharing', () => { diff --git a/src/api/contacts.ts b/src/api/contacts.ts index 5330c0ec..826a1748 100644 --- a/src/api/contacts.ts +++ b/src/api/contacts.ts @@ -4,6 +4,7 @@ import { CAPABILITIES } from './types'; import type { ContactCard, ContactMedia, AddressBook } from './types'; import { generateUUID } from '../lib/uuid'; import { contactFromWire, contactToWire } from '../lib/contact-wire'; +import { accountSupports } from '../lib/account-capability'; const USING = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS]; @@ -55,18 +56,19 @@ export function getContactsAccountId(): string { /** * Every account that may hold address books: the contacts primary first, then - * accounts that advertise the contacts capability or are non-personal (shared - * / group) accounts - Stalwart doesn't always advertise capabilities on group - * accounts even when they have contact resources. Mirrors the webmail's - * getContactCapableAccountIds(). + * the accounts the user may use contacts with (lib/account-capability: a + * shared account that lists its capabilities without contacts is left out, + * one that lists none falls back to the server-wide capability, webmail + * #1239). Mirrors the webmail's getContactCapableAccountIds(). */ export function getContactCapableAccountIds(): string[] { const primaryId = getContactsAccountId(); - const accounts = jmapClient.currentSession?.accounts ?? {}; + const session = jmapClient.currentSession; + const accounts = session?.accounts ?? {}; const out: string[] = []; for (const [id, account] of Object.entries(accounts)) { if (id === primaryId) continue; - if (account.accountCapabilities?.[CAPABILITIES.CONTACTS] || account.isPersonal === false) { + if (accountSupports(account, session?.capabilities, CAPABILITIES.CONTACTS)) { out.push(id); } } diff --git a/src/api/files.ts b/src/api/files.ts index 64c9c02a..806bcd1e 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -6,6 +6,7 @@ import { batched, JMAPSetError, requireMethodResult } from './jmap-result'; import { decodeFileNodeName, getUniqueName } from '../lib/filenode-name'; import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from '../lib/file-name-rules'; import { mediaTypeEssence } from '../lib/media-type'; +import { accountSupports } from '../lib/account-capability'; /** * What a FileNode is: a file, a folder or a symlink. @@ -119,16 +120,15 @@ export function filesAccountId(): string { // capability. A server can advertise urn:ietf:params:jmap:filenode while a // specific account has its jmap-file-node-* permissions revoked, in which case // the capability is absent from that account's accountCapabilities and every -// FileNode action fails with an authorization error (#563). Non-personal -// (shared/group) accounts don't always advertise per-account, so treat those -// as capable. Mirrors the webmail's `supportsFiles`. +// FileNode action fails with an authorization error (#563). A shared account +// that lists no capabilities at all falls back to the server-wide one; one +// that lists them without FileNode is not capable (lib/account-capability, +// webmail #1239). Mirrors the webmail's `supportsFiles`. export function accountSupportsFiles( account: JMAPAccountInfo | undefined, sessionCapabilities: Record | undefined, ): boolean { - if (!sessionCapabilities || !(CAPABILITIES.FILES in sessionCapabilities)) return false; - if (!account) return false; - return account.accountCapabilities?.[CAPABILITIES.FILES] != null || !account.isPersonal; + return accountSupports(account, sessionCapabilities, CAPABILITIES.FILES); } export function supportsFiles(): boolean { @@ -318,15 +318,13 @@ export async function getAllFileNodes(): Promise { } // Accounts (primary + shared/group) that can hold FileNodes: any non-primary -// account that advertises the filenode capability or is non-personal, since -// Stalwart doesn't always advertise capabilities on shared accounts. -function filesCapableAccountIds(): string[] { +// account the user may use FileNode with (accountSupportsFiles). +export function filesCapableAccountIds(): string[] { const primaryId = filesAccountId(); - const accounts = jmapClient.currentSession?.accounts ?? {}; + const session = jmapClient.currentSession; + const accounts = session?.accounts ?? {}; const rest = Object.entries(accounts) - .filter(([id, info]) => - id !== primaryId && - (info.accountCapabilities?.[CAPABILITIES.FILES] != null || !info.isPersonal)) + .filter(([id, info]) => id !== primaryId && accountSupportsFiles(info, session?.capabilities)) .map(([id]) => id); return [primaryId, ...rest]; } From fe45537037ac3d34e4911a1d205f3dfa38597ede Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:13:57 +0100 Subject: [PATCH 02/14] feat: open a calendar link on the view and day it names --- .../__tests__/calendar-date-link.test.ts | 56 +++++++++++++++++++ src/navigation/__tests__/linking.test.ts | 4 +- src/navigation/linking.ts | 29 ++++++++-- src/navigation/pending-calendar-date.ts | 50 +++++++++++++++++ src/screens/CalendarScreen.tsx | 41 ++++++++++++++ 5 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 src/navigation/__tests__/calendar-date-link.test.ts create mode 100644 src/navigation/pending-calendar-date.ts diff --git a/src/navigation/__tests__/calendar-date-link.test.ts b/src/navigation/__tests__/calendar-date-link.test.ts new file mode 100644 index 00000000..67d13b72 --- /dev/null +++ b/src/navigation/__tests__/calendar-date-link.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { handleDeepLink, parseDeepLink } from '../linking'; +import { parseCalendarLinkDate, usePendingCalendarDate } from '../pending-calendar-date'; +import { usePendingCalendarOpen } from '../pending-calendar-open'; + +// A `/calendar//` link opened only the Calendar tab, on +// whatever day it showed (file 10 "date deep links", 05 "Deep links to +// calendar/event"). Webmail lib/deep-links.ts parseCalendarPath. + +const nav = () => ({ isReady: () => true, navigate: vi.fn() }); + +describe('calendar date deep links', () => { + it('parses the view and the day, and only real days', () => { + expect(parseDeepLink('bulwarkmobile://calendar/week/2026-08-29')) + .toEqual({ kind: 'calendar', view: 'week', date: '2026-08-29' }); + expect(parseDeepLink('https://mail.example.com/de/calendar/day/2026-02-03')) + .toEqual({ kind: 'calendar', view: 'day', date: '2026-02-03' }); + expect(parseDeepLink('bulwarkmobile://calendar/agenda')).toEqual({ kind: 'calendar', view: 'agenda' }); + expect(parseDeepLink('bulwarkmobile://calendar/tasks')).toEqual({ kind: 'calendar', view: 'tasks' }); + // A bare date keeps the view on screen. + expect(parseDeepLink('bulwarkmobile://calendar/2026-08-06')).toEqual({ kind: 'calendar', date: '2026-08-06' }); + // 2026-02-31 does not roll over into March. + expect(parseDeepLink('bulwarkmobile://calendar/month/2026-02-31')).toEqual({ kind: 'calendar', view: 'month' }); + expect(parseDeepLink('bulwarkmobile://calendar/year/2026-08-06')).toEqual({ kind: 'calendar' }); + }); + + it('reads the day as a local calendar day', () => { + const d = parseCalendarLinkDate('2026-08-06')!; + expect([d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()]).toEqual([2026, 7, 6, 0]); + expect(parseCalendarLinkDate('2026-13-01')).toBeNull(); + expect(parseCalendarLinkDate('06.08.2026')).toBeNull(); + }); + + it('parks the view and day for the Calendar tab', async () => { + const navigation = nav(); + const link = parseDeepLink('bulwarkmobile://calendar/day/2026-08-06')!; + expect(await handleDeepLink(link, { navigation: navigation as never, resolveThreadId: async () => null })).toBe(true); + expect(usePendingCalendarDate.getState().consume()).toEqual({ view: 'day', date: '2026-08-06' }); + expect(usePendingCalendarOpen.getState().consume()).toBeNull(); + expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Calendar' }); + + // A plain /calendar link parks nothing. + await handleDeepLink(parseDeepLink('bulwarkmobile://calendar')!, { navigation: navigation as never, resolveThreadId: async () => null }); + expect(usePendingCalendarDate.getState().consume()).toBeNull(); + }); + + // No RN renderer in the test env: check the screen's wiring instead. + it('is applied by the Calendar screen', () => { + const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/CalendarScreen.tsx'), 'utf8'); + expect(screen).toMatch(/const link = usePendingCalendarDate\.getState\(\)\.consume\(\);\s*if \(link\) applyDateLink\(link\);/); + expect(screen).toMatch(/const mode: ViewMode = link\.view && link\.view !== 'tasks' \? link\.view : viewMode;/); + expect(screen).toMatch(/setFocus\(\(prev\) => \(\{ date, nonce: prev\.nonce \+ 1 \}\)\);\s*setWindowState\(\(prev\) =>\s*mode === viewMode/); + }); +}); diff --git a/src/navigation/__tests__/linking.test.ts b/src/navigation/__tests__/linking.test.ts index 88d2e0d4..be7c5cf3 100644 --- a/src/navigation/__tests__/linking.test.ts +++ b/src/navigation/__tests__/linking.test.ts @@ -22,7 +22,7 @@ describe('parseDeepLink', () => { it('parses calendar, contacts, files and settings links', () => { expect(parseDeepLink('bulwarkmobile://calendar/event/E1')).toEqual({ kind: 'calendar', eventId: 'E1' }); - expect(parseDeepLink('bulwarkmobile://calendar/week/2026-08-29')).toEqual({ kind: 'calendar', date: '2026-08-29' }); + expect(parseDeepLink('bulwarkmobile://calendar/week/2026-08-29')).toEqual({ kind: 'calendar', view: 'week', date: '2026-08-29' }); expect(parseDeepLink('bulwarkmobile://contacts')).toEqual({ kind: 'contacts' }); expect(parseDeepLink('bulwarkmobile://files')).toEqual({ kind: 'files' }); expect(parseDeepLink('bulwarkmobile://settings/notifications')).toEqual({ kind: 'settings', tab: 'notifications' }); @@ -145,7 +145,7 @@ describe('handleDeepLink', () => { }); expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Calendar' }); - // A date or view link just opens the tab. + // A date or view link opens no event (calendar-date-link.test.ts). await handleDeepLink( { kind: 'calendar', date: '2026-08-29' }, { navigation: navigation as never, resolveThreadId: async () => null }, diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index b08028b5..b8ec594d 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -8,6 +8,12 @@ import { parseMailtoUrl } from '../lib/mailto'; import type { RootStackParamList } from './types'; import { setPendingSettingsTab } from './pending-settings-tab'; import { setPendingCalendarOpen } from './pending-calendar-open'; +import { + CALENDAR_LINK_VIEWS, + parseCalendarLinkDate, + setPendingCalendarDate, + type CalendarLinkView, +} from './pending-calendar-date'; import { setPendingMailSearch } from './pending-mail-search'; import { setPendingMailFolder } from './pending-mail-folder'; import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link'; @@ -38,8 +44,9 @@ export type DeepLink = | { kind: 'search'; query: string } // `jmapAccountId`: the JMAP account owning the event (`?account=`), for // one on a calendar shared with the user; `accountId`: the signed-in - // account it belongs to (`?appAccount=`, from widgets). - | { kind: 'calendar'; eventId?: string; date?: string; jmapAccountId?: string; accountId?: string } + // account it belongs to (`?appAccount=`, from widgets). `view` and `date` + // (`YYYY-MM-DD`): the view and day a `/calendar//` link shows. + | { kind: 'calendar'; eventId?: string; view?: CalendarLinkView; date?: string; jmapAccountId?: string; accountId?: string } | { kind: 'contact'; contactId: string } | { kind: 'contacts' } | { kind: 'files' } @@ -149,8 +156,16 @@ export function parseDeepLink(url: string): DeepLink | null { ...(appAccountId ? { accountId: appAccountId } : {}), }; } - const date = [kind, value].find((s) => s && /^\d{4}-\d{2}-\d{2}$/.test(s)); - return { kind: 'calendar', date }; + // `/calendar/[/]`, or a bare `/calendar/` that keeps + // the view on screen (webmail parseCalendarPath). + if (kind && (CALENDAR_LINK_VIEWS as readonly string[]).includes(kind)) { + return { + kind: 'calendar', + view: kind as CalendarLinkView, + ...(parseCalendarLinkDate(value) ? { date: value } : {}), + }; + } + return { kind: 'calendar', ...(parseCalendarLinkDate(kind) ? { date: kind } : {}) }; } case 'contacts': { if (kind && kind !== 'new') return { kind: 'contact', contactId: decodeSegment(kind) }; @@ -298,6 +313,12 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr serverId: link.eventId, accountId: link.jmapAccountId, }); + } else if (link.view || link.date) { + // The Calendar tab moves to the view and day. + setPendingCalendarDate({ + ...(link.view ? { view: link.view } : {}), + ...(link.date ? { date: link.date } : {}), + }); } navigation.navigate('MainTabs', { screen: 'Calendar' } as never); return true; diff --git a/src/navigation/pending-calendar-date.ts b/src/navigation/pending-calendar-date.ts new file mode 100644 index 00000000..4fb1e1c2 --- /dev/null +++ b/src/navigation/pending-calendar-date.ts @@ -0,0 +1,50 @@ +import { create } from 'zustand'; + +/** The views a calendar link names (webmail lib/deep-links.ts CALENDAR_VIEWS). */ +export const CALENDAR_LINK_VIEWS = ['month', 'week', 'day', 'agenda', 'tasks'] as const; +export type CalendarLinkView = typeof CALENDAR_LINK_VIEWS[number]; + +/** A `/calendar//` link: the view and the day to show. */ +export interface CalendarDateLink { + view?: CalendarLinkView; + /** `YYYY-MM-DD`, a local calendar day. */ + date?: string; +} + +/** + * `YYYY-MM-DD` as a local-midnight Date; null when malformed or impossible + * (2026-02-31 would otherwise roll over into March). The webmail's + * parseLinkDate. + */ +export function parseCalendarLinkDate(value: string | null | undefined): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? ''); + if (!match) return null; + const [, y, m, d] = match; + const date = new Date(Number(y), Number(m) - 1, Number(d)); + if (Number.isNaN(date.getTime())) return null; + if (date.getMonth() !== Number(m) - 1 || date.getDate() !== Number(d)) return null; + return date; +} + +// A date link arrives before CalendarScreen is mounted (cold start) or while +// it shows another day. It is parked here and consumed by the screen, like +// pending-calendar-open. +interface PendingCalendarDateState { + link: CalendarDateLink | null; + set: (link: CalendarDateLink | null) => void; + consume: () => CalendarDateLink | null; +} + +export const usePendingCalendarDate = create((set, get) => ({ + link: null, + set: (link) => set({ link }), + consume: () => { + const link = get().link; + if (link) set({ link: null }); + return link; + }, +})); + +export function setPendingCalendarDate(link: CalendarDateLink | null): void { + usePendingCalendarDate.getState().set(link); +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index d769e6ec..25c679e6 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -107,6 +107,11 @@ import { useUserCalendarAddresses } from '../lib/calendar-user-addresses'; import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store'; import { startCalendarNotificationSync } from '../lib/calendar-notifications'; import { useCalendarReminderOpen } from '../lib/calendar-reminder-open'; +import { + parseCalendarLinkDate, + usePendingCalendarDate, + type CalendarDateLink, +} from '../navigation/pending-calendar-date'; import { shareEventICS } from '../lib/calendar-ics-export'; import * as Clipboard from 'expo-clipboard'; import type { Calendar, CalendarEvent } from '../api/types'; @@ -516,6 +521,42 @@ export default function CalendarScreen() { [viewMode, visibleDate, focus.date], ); + // A `/calendar//` link (webmail applyCalendarDeepLink): the + // view it names, on the day it names. Without a view the view on screen + // stays; `tasks`, a view the phone has as a sheet, opens the task list. + const applyDateLink = React.useCallback( + (link: CalendarDateLink) => { + if (link.view === 'tasks') { + if (enableCalendarTasks) { + setTasksInitialId(null); + setTasksVisible(true); + } + } + const mode: ViewMode = link.view && link.view !== 'tasks' ? link.view : viewMode; + const date = parseCalendarLinkDate(link.date); + if (!date) { + changeViewMode(mode); + return; + } + setSelectedDate(date); + setVisibleDate(null); + setFocus((prev) => ({ date, nonce: prev.nonce + 1 })); + setWindowState((prev) => + mode === viewMode + ? windowStateForJump(prev, windowMode(mode), date, windowOptions) + : freshScrollWindowState(windowMode(mode), date), + ); + setViewMode(mode); + }, + [enableCalendarTasks, viewMode, changeViewMode, windowOptions], + ); + const pendingDateLink = usePendingCalendarDate((s) => s.link); + React.useEffect(() => { + if (!pendingDateLink) return; + const link = usePendingCalendarDate.getState().consume(); + if (link) applyDateLink(link); + }, [pendingDateLink, applyDateLink]); + // The views, listed at the top of the drawer. const viewOptions = React.useMemo( () => [ From ecefd1cbcc03ce1f6004e8cd36480a257ae3eebc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:16:34 +0100 Subject: [PATCH 03/14] feat: open new-contact and edit-contact links in the contact form --- src/navigation/__tests__/contact-link.test.ts | 51 ++++++++++++++++++ src/navigation/linking.ts | 32 +++++++++-- src/navigation/types.ts | 3 +- src/screens/ContactFormScreen.tsx | 53 +++++++++++++++++-- 4 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 src/navigation/__tests__/contact-link.test.ts diff --git a/src/navigation/__tests__/contact-link.test.ts b/src/navigation/__tests__/contact-link.test.ts new file mode 100644 index 00000000..66b6bead --- /dev/null +++ b/src/navigation/__tests__/contact-link.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { handleDeepLink, parseDeepLink } from '../linking'; + +// `/contacts/new` opened the Contacts tab with nothing, and `/contacts//edit` +// only the contact (file 10 "deep links new/edit", 06 "Contact deep links"). +// Webmail lib/deep-links.ts parseContactsPath. + +const nav = () => ({ isReady: () => true, navigate: vi.fn() }); +const open = async (url: string) => { + const navigation = nav(); + const link = parseDeepLink(url); + expect(await handleDeepLink(link!, { navigation: navigation as never, resolveThreadId: async () => null })).toBe(true); + return navigation.navigate.mock.calls[0]; +}; + +describe('contact deep links', () => { + it('parses new and edit links, and the older query form', () => { + expect(parseDeepLink('bulwarkmobile://contacts/new')).toEqual({ kind: 'contact-new' }); + expect(parseDeepLink('https://mail.example.com/contacts/new?addEmail=a%40b.co&name=Ada%20Lovelace')) + .toEqual({ kind: 'contact-new', email: 'a@b.co', name: 'Ada Lovelace' }); + expect(parseDeepLink('bulwarkmobile://contacts/new?email=a@b.co&addName=Ada')) + .toEqual({ kind: 'contact-new', email: 'a@b.co', name: 'Ada' }); + expect(parseDeepLink('bulwarkmobile://contacts/C%2F1/edit')).toEqual({ kind: 'contact', contactId: 'C/1', edit: true }); + expect(parseDeepLink('bulwarkmobile://contacts/C1')).toEqual({ kind: 'contact', contactId: 'C1' }); + expect(parseDeepLink('https://mail.example.com/contacts?contactId=C1&view=edit')) + .toEqual({ kind: 'contact', contactId: 'C1', edit: true }); + expect(parseDeepLink('https://mail.example.com/contacts?addEmail=a@b.co')) + .toEqual({ kind: 'contact-new', email: 'a@b.co' }); + expect(parseDeepLink('bulwarkmobile://contacts')).toEqual({ kind: 'contacts' }); + }); + + it('opens the contact form, filled in or on the card', async () => { + expect(await open('bulwarkmobile://contacts/new')).toEqual(['ContactForm', {}]); + expect(await open('bulwarkmobile://contacts/new?addEmail=a@b.co&name=Ada')) + .toEqual(['ContactForm', { prefill: { email: 'a@b.co', name: 'Ada' } }]); + expect(await open('bulwarkmobile://contacts/new?name=Ada')).toEqual(['ContactForm', { prefill: { name: 'Ada' } }]); + expect(await open('bulwarkmobile://contacts/C1/edit')).toEqual(['ContactForm', { contactId: 'C1' }]); + expect(await open('bulwarkmobile://contacts/C1')).toEqual(['ContactDetail', { contactId: 'C1' }]); + }); + + // No RN renderer in the test env: check the form's wiring instead. + it('fills the form from a name-only link and waits for the card of an edit link', () => { + const form = fs.readFileSync(path.resolve(__dirname, '../../screens/ContactFormScreen.tsx'), 'utf8'); + expect(form).toMatch(/if \(prefill\?\.email \|\| prefill\?\.name\) \{/); + expect(form).toMatch(/if \(isEdit && !existing\) return;/); + expect(form).toMatch(/if \(filledFromExisting\.current \|\| !existing\) return;/); + expect(form).toMatch(/const asGroup = asGroupParam \|\| existing\?\.kind === 'group';/); + }); +}); diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index b8ec594d..28e4fa5d 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -47,7 +47,10 @@ export type DeepLink = // account it belongs to (`?appAccount=`, from widgets). `view` and `date` // (`YYYY-MM-DD`): the view and day a `/calendar//` link shows. | { kind: 'calendar'; eventId?: string; view?: CalendarLinkView; date?: string; jmapAccountId?: string; accountId?: string } - | { kind: 'contact'; contactId: string } + // `edit`: `/contacts//edit` opens the contact's editor. + | { kind: 'contact'; contactId: string; edit?: true } + // `/contacts/new`, with `?email=`/`?addEmail=` and `?name=`/`?addName=`. + | { kind: 'contact-new'; email?: string; name?: string } | { kind: 'contacts' } | { kind: 'files' } | { kind: 'settings'; tab?: string } @@ -168,7 +171,24 @@ export function parseDeepLink(url: string): DeepLink | null { return { kind: 'calendar', ...(parseCalendarLinkDate(kind) ? { date: kind } : {}) }; } case 'contacts': { - if (kind && kind !== 'new') return { kind: 'contact', contactId: decodeSegment(kind) }; + // Webmail parseContactsPath, with the older query form plugins and + // bookmarks still emit (`?contactId=[&view=edit]`, `?addEmail=`). + const newContact = (email: string | null, name: string | null): DeepLink => ({ + kind: 'contact-new', + ...(email ? { email } : {}), + ...(name ? { name } : {}), + }); + if (kind === 'new') { + return newContact(search.get('email') ?? search.get('addEmail'), search.get('name') ?? search.get('addName')); + } + const contactId = kind ? decodeSegment(kind) : search.get('contactId'); + if (contactId) { + const edit = kind ? value === 'edit' : search.get('view') === 'edit'; + return { kind: 'contact', contactId, ...(edit ? { edit: true as const } : {}) }; + } + if (search.get('addEmail') || search.get('addName')) { + return newContact(search.get('addEmail'), search.get('addName')); + } return { kind: 'contacts' }; } case 'files': @@ -323,7 +343,13 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr navigation.navigate('MainTabs', { screen: 'Calendar' } as never); return true; case 'contact': - navigation.navigate('ContactDetail', { contactId: link.contactId }); + if (link.edit) navigation.navigate('ContactForm', { contactId: link.contactId }); + else navigation.navigate('ContactDetail', { contactId: link.contactId }); + return true; + case 'contact-new': + navigation.navigate('ContactForm', link.email || link.name + ? { prefill: { ...(link.email ? { email: link.email } : {}), ...(link.name ? { name: link.name } : {}) } } + : {}); return true; case 'contacts': navigation.navigate('MainTabs', { screen: 'Contacts' } as never); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 098f7c8f..ac96d074 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -90,7 +90,8 @@ export type RootStackParamList = { contactId?: string; addressBookId?: string; asGroup?: boolean; - prefill?: { email: string; name?: string }; + /** "Add to contacts" and `/contacts/new?email=&name=` links; either may be missing. */ + prefill?: { email?: string; name?: string }; /** Pre-selected member ids when creating a group. */ memberIds?: string[]; }; diff --git a/src/screens/ContactFormScreen.tsx b/src/screens/ContactFormScreen.tsx index c669c162..7555cc4a 100644 --- a/src/screens/ContactFormScreen.tsx +++ b/src/screens/ContactFormScreen.tsx @@ -245,7 +245,7 @@ export default function ContactFormScreen() { const route = useRoute(); const t = useLocaleStore((s) => s.t); const opts = React.useMemo(() => formOptions(t), [t]); - const { contactId, addressBookId: initialBook, asGroup, prefill, memberIds: initialMemberIds } = route.params || {}; + const { contactId, addressBookId: initialBook, asGroup: asGroupParam, prefill, memberIds: initialMemberIds } = route.params || {}; const isEdit = !!contactId; const addressBooks = useContactsStore((s) => s.addressBooks); @@ -258,6 +258,8 @@ export default function ContactFormScreen() { () => (contactId ? allContacts.find((c) => c.id === contactId) : undefined), [allContacts, contactId], ); + // An edit link (`/contacts//edit`) does not know it names a group. + const asGroup = asGroupParam || existing?.kind === 'group'; const existingKeywords = React.useMemo(() => { const counts = new Map(); for (const contact of allContacts) { @@ -280,12 +282,14 @@ export default function ContactFormScreen() { const init = blankForm(); init.addressBookId = initialBook || getDefaultAddressBookId() || ''; if (initialMemberIds?.length) init.members = initialMemberIds; - if (prefill?.email) { + if (prefill?.email || prefill?.name) { // "Add sender to contacts": split the display name like the webmail // (first word → given, rest → surname) and never let a mailbox-shaped - // name through (#672). - const mailbox = splitMailbox(prefill.name ? `${prefill.name} <${prefill.email}>` : prefill.email); - init.emails = [{ address: mailbox.email, context: '' }]; + // name through (#672). A `/contacts/new` link may carry only a name. + const mailbox = prefill.email + ? splitMailbox(prefill.name ? `${prefill.name} <${prefill.email}>` : prefill.email) + : { email: '', name: prefill.name ?? '' }; + if (mailbox.email) init.emails = [{ address: mailbox.email, context: '' }]; const parts = (mailbox.name || '').split(/\s+/).filter(Boolean); if (parts.length > 0) { init.given = parts[0]; @@ -310,6 +314,19 @@ export default function ContactFormScreen() { const [datePickerIndex, setDatePickerIndex] = React.useState(null); const [memberPickerOpen, setMemberPickerOpen] = React.useState(false); + // An edit link can open the form before the contacts have loaded: the + // card's fields go in when it arrives, unless the user has typed already. + const filledFromExisting = React.useRef(!!existing); + React.useEffect(() => { + if (filledFromExisting.current || !existing) return; + filledFromExisting.current = true; + if (dirty) return; + const members = asGroup + ? selectGroupMembers({ contacts: allContacts }, existing.id).map((m) => m.id) + : []; + setForm(contactToForm(existing, members)); + }, [existing, dirty, asGroup, allContacts]); + React.useEffect(() => { if (!form.addressBookId) { const fallback = getDefaultAddressBookId(); @@ -344,6 +361,8 @@ export default function ContactFormScreen() { }; const handleSave = async () => { + // Never turn an edit of a card that isn't there into a new card. + if (isEdit && !existing) return; const orgName = form.orgs[0]?.name.trim() || ''; if (asGroup) { if (!form.given.trim()) { @@ -647,6 +666,28 @@ export default function ContactFormScreen() { /> ); + if (isEdit && !existing) { + return ( + + + + + + {t('contacts.contact', 'Contact')} + + + {t('contacts.detail.not_found', 'Contact not found')} + + + ); + } + if (asGroup) { // Groups are a name plus a member list - none of the person fields apply. return ( @@ -1477,6 +1518,8 @@ function formatDateAsISO(d: Date): string { function makeStyles(c: ThemePalette) { return StyleSheet.create({ container: { flex: 1, backgroundColor: c.background }, + missing: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + missingText: { ...typography.body, color: c.textMuted }, scrollContent: { paddingVertical: spacing.md, paddingBottom: spacing.xxxl * 2, From e3fc67e7711bf6b26f0f1c8ded5228ed40d56191 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:24:31 +0100 Subject: [PATCH 04/14] feat: step to the previous or next file in the files preview --- .../email/AttachmentPreviewModal.tsx | 5 +- src/components/files/FilePreviewModal.tsx | 49 ++++++++++++++++++- .../files-preview-neighbours.test.ts | 36 ++++++++++++++ src/lib/files-preview-neighbours.ts | 14 ++++++ src/screens/FilesScreen.tsx | 43 +++++++++++++--- 5 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 src/lib/__tests__/files-preview-neighbours.test.ts create mode 100644 src/lib/files-preview-neighbours.ts diff --git a/src/components/email/AttachmentPreviewModal.tsx b/src/components/email/AttachmentPreviewModal.tsx index 9ca523d7..0ce3c36b 100644 --- a/src/components/email/AttachmentPreviewModal.tsx +++ b/src/components/email/AttachmentPreviewModal.tsx @@ -57,6 +57,8 @@ interface Props { onDownload?: () => void; /** Render in place (a split view's side pane) instead of a full-screen modal. */ docked?: boolean; + /** Extra header buttons, after the title (Files: previous / next file). */ + headerActions?: React.ReactNode; } interface ViewerWebViewProps { @@ -148,7 +150,7 @@ function PdfPagesView({ fileUri, viewerUri, background, onFailed }: { * place that opens a file. */ export function AttachmentPreviewModal({ - item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false, + item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false, headerActions, }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -393,6 +395,7 @@ export function AttachmentPreviewModal({ {item?.name ?? title ?? ''} + {headerActions} {item && !showsFallback ? ( ; @@ -16,6 +20,13 @@ interface Props { onClose: () => void; /** Show it in a side pane instead of a full-screen modal. */ docked?: boolean; + /** + * Step to the folder's previous / next previewable file; the buttons show + * when either is given and the missing one is disabled (webmail + * previewNeighbours). + */ + onPrevious?: () => void; + onNext?: () => void; } /** @@ -23,7 +34,9 @@ interface Props { * FileNode, downloaded with the same authenticated download as "Download" * (token refresh, client certificate). */ -export function FilePreviewModal({ file, onClose, docked = false }: Props) { +export function FilePreviewModal({ file, onClose, docked = false, onPrevious, onNext }: Props) { + const c = useColors(); + const t = useLocaleStore((s) => s.t); const preview = useFilePreview({ onClose }); const { open, reset } = preview; @@ -39,5 +52,37 @@ export function FilePreviewModal({ file, onClose, docked = false }: Props) { return reset; }, [blobId, accountId, name, type, size, open, reset]); - return ; + const headerActions = onPrevious || onNext ? ( + <> + + + + + + + + ) : undefined; + + return ; } + +const styles = StyleSheet.create({ + // The preview header's own button size. + button: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/src/lib/__tests__/files-preview-neighbours.test.ts b/src/lib/__tests__/files-preview-neighbours.test.ts new file mode 100644 index 00000000..1b84c9c6 --- /dev/null +++ b/src/lib/__tests__/files-preview-neighbours.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { previewNeighbours } from '../files-preview-neighbours'; + +// The Files preview had no way to the folder's next file (file 10 "next/ +// previous in preview"); the webmail's ←/→ step through the folder's other +// previewable files. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, rel), 'utf8'); + +describe('previous / next in the Files preview', () => { + const files = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + + it('finds the neighbours of the open file', () => { + expect(previewNeighbours(files, { id: 'b' })).toEqual({ previous: { id: 'a' }, next: { id: 'c' } }); + expect(previewNeighbours(files, { id: 'a' })).toEqual({ previous: undefined, next: { id: 'b' } }); + expect(previewNeighbours(files, { id: 'c' })).toEqual({ previous: { id: 'b' }, next: undefined }); + expect(previewNeighbours(files, { id: 'gone' })).toEqual({}); + expect(previewNeighbours(files, null)).toEqual({}); + }); + + // No RN renderer in the test env: check the wiring instead. + it('puts the buttons in the preview header, fed from the listed files it can show', () => { + const modal = read('../../components/email/AttachmentPreviewModal.tsx'); + expect(modal).toMatch(/\s*\{headerActions\}/); + const preview = read('../../components/files/FilePreviewModal.tsx'); + expect(preview).toMatch(/accessibilityLabel=\{t\('files\.previous_file', 'Previous file'\)\}/); + expect(preview).toMatch(/accessibilityLabel=\{t\('files\.next_file', 'Next file'\)\}/); + expect(preview).toMatch(/headerActions=\{headerActions\}/); + const screen = read('../../screens/FilesScreen.tsx'); + expect(screen).toMatch(/visibleFiles\.filter\(\(r\) => !isFolder\(r\) && !isSymlink\(r\) && canPreviewInApp\(r\)\)/); + expect(screen.match(/onPrevious=\{showPrevious\}/g)).toHaveLength(2); + expect(screen.match(/onNext=\{showNext\}/g)).toHaveLength(2); + }); +}); diff --git a/src/lib/files-preview-neighbours.ts b/src/lib/files-preview-neighbours.ts new file mode 100644 index 00000000..5f409efe --- /dev/null +++ b/src/lib/files-preview-neighbours.ts @@ -0,0 +1,14 @@ +/** + * The previous and next of `current` among `files`, by id, for the Files + * preview's previous / next buttons (webmail previewNeighbours); undefined + * where there is none, and both when `current` isn't among them. + */ +export function previewNeighbours( + files: T[], + current: { id: string } | null, +): { previous?: T; next?: T } { + if (!current) return {}; + const i = files.findIndex((f) => f.id === current.id); + if (i === -1) return {}; + return { previous: files[i - 1], next: files[i + 1] }; +} diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 1be3045b..a8d806ba 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -84,6 +84,7 @@ import { useLocaleStore, type TranslateFn } from '../stores/locale-store'; import Dialog from '../components/Dialog'; import ShareSheet from '../components/files/ShareSheet'; import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal'; +import { previewNeighbours } from '../lib/files-preview-neighbours'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; // A file row carries the display name alongside the rest of the node. @@ -464,12 +465,17 @@ export default function FilesScreen() { } }, [t]); + // Every way into the preview: a tap, the row's actions, previous / next. + const openPreview = useCallback((row: FileRow) => { + setPreviewTarget(row); + }, []); + // Tap on a file: the in-app preview for the types it shows (lib/file-preview), // the other app for the rest. const openFile = useCallback((row: FileRow) => { - if (canPreviewInApp(row)) setPreviewTarget(row); + if (canPreviewInApp(row)) openPreview(row); else void openFileWith(row); - }, [openFileWith]); + }, [openFileWith, openPreview]); const downloadFile = useCallback(async (row: FileRow) => { if (!row.blobId) return; @@ -552,7 +558,7 @@ export default function FilesScreen() { // In the trash a file can be looked at; a folder is restored before // it is opened. Everything else is in the row's actions. if (row.trash) { - if (!isFolder(row) && !isSymlink(row) && canPreviewInApp(row)) setPreviewTarget(row); + if (!isFolder(row) && !isSymlink(row) && canPreviewInApp(row)) openPreview(row); else setActionsTarget(row); return; } @@ -566,7 +572,7 @@ export default function FilesScreen() { if (isSymlink(row)) return; openFile(row); }, - [selectionMode, toggleSelect, openFile], + [selectionMode, toggleSelect, openFile, openPreview], ); const handleRowLongPress = useCallback((row: FileRow) => { @@ -1296,6 +1302,16 @@ export default function FilesScreen() { ); } + // The preview steps through the folder's other files it can show, in the + // order of the list (webmail previewNeighbours). + const previewable = useMemo( + () => visibleFiles.filter((r) => !isFolder(r) && !isSymlink(r) && canPreviewInApp(r)), + [visibleFiles], + ); + const { previous: previousFile, next: nextFile } = previewNeighbours(previewable, previewTarget); + const showPrevious = previousFile ? () => openPreview(previousFile) : undefined; + const showNext = nextFile ? () => openPreview(nextFile) : undefined; + // On a wide window an open file shows beside the list instead of covering it. const previewDocked = isSplit && previewTarget != null; return ( @@ -1306,7 +1322,13 @@ export default function FilesScreen() { {previewDocked && ( - setPreviewTarget(null)} /> + setPreviewTarget(null)} + onPrevious={showPrevious} + onNext={showNext} + /> )} @@ -1348,7 +1370,7 @@ export default function FilesScreen() { onClose={() => setActionsTarget(null)} onPreview={(r) => { setActionsTarget(null); - setPreviewTarget(r); + openPreview(r); }} onOpenWith={(r) => { setActionsTarget(null); @@ -1404,7 +1426,14 @@ export default function FilesScreen() { onChangeDir={(d) => setSetting('filesDefaultSortDir', d)} /> - {!isSplit && setPreviewTarget(null)} />} + {!isSplit && ( + setPreviewTarget(null)} + onPrevious={showPrevious} + onNext={showNext} + /> + )} Date: Sat, 31 Oct 2026 05:28:20 +0100 Subject: [PATCH 05/14] feat: list starred folders and recent files at the top of files --- src/lib/__tests__/files-places.test.ts | 117 ++++++++++++ src/lib/files-places.ts | 111 ++++++++++++ src/screens/FilesScreen.tsx | 236 +++++++++++++++++++++++-- src/stores/files-places-store.ts | 68 +++++++ 4 files changed, 521 insertions(+), 11 deletions(-) create mode 100644 src/lib/__tests__/files-places.test.ts create mode 100644 src/lib/files-places.ts create mode 100644 src/stores/files-places-store.ts diff --git a/src/lib/__tests__/files-places.test.ts b/src/lib/__tests__/files-places.test.ts new file mode 100644 index 00000000..3374c483 --- /dev/null +++ b/src/lib/__tests__/files-places.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { FileNode } from '../../api/types'; +import { + RECENT_FILES_MAX, + addRecentFile, + dropFavoritesUnder, + folderPathOf, + moveFavorites, + nodePlace, + pruneRecentFiles, + resolveFolderPath, + toggleFavorite, +} from '../files-places'; +import { NO_FILES_PLACES, useFilesPlacesStore } from '../../stores/files-places-store'; + +// The Files tab had no starred folders or recent files (file 10 "starred and +// recent"). Webmail stores/file-store.ts favorites / recentFiles, +// navigateByPath and revealNode. + +const folder = (id: string, name: string, parentId: string | null = null, extra: Partial = {}) => + ({ id, name, parentId, blobId: null, ...extra }) as FileNode; +const file = (id: string, name: string, parentId: string | null = null, extra: Partial = {}) => + ({ id, name, parentId, blobId: `b-${id}`, ...extra }) as FileNode; + +const nodes: FileNode[] = [ + folder('d1', 'Docs'), + folder('d2', 'Sub', 'd1'), + file('f1', 'a.pdf', 'd2'), + file('f0', 'top.txt'), + // A drive shared with the user: the owner's folder above the shared one + // is a placeholder (Stalwart 1.0). + folder('o:top', 'Owner top', null, { isShared: true, discoverableOnly: true }), + folder('o:s', 'Docs', 'o:top', { isShared: true }), + file('o:f', 'shared.txt', 'o:s', { isShared: true }), +]; + +describe('files places', () => { + it('walks a path from the root, the own folder first', () => { + expect(resolveFolderPath(nodes, '/Docs/Sub')).toEqual([{ id: 'd1', name: 'Docs' }, { id: 'd2', name: 'Sub' }]); + expect(resolveFolderPath(nodes, '/')).toEqual([]); + expect(resolveFolderPath(nodes, '/Docs/Gone')).toBeNull(); + // A file is not a folder to stand in. + expect(resolveFolderPath(nodes, '/top.txt')).toBeNull(); + expect(folderPathOf([{ id: 'd1', name: 'Docs' }, { id: 'd2', name: 'Sub' }])).toBe('/Docs/Sub'); + }); + + it('finds where a node is listed, a shared one under its shared root', () => { + expect(nodePlace(nodes, 'f1')?.stack).toEqual([{ id: 'd1', name: 'Docs' }, { id: 'd2', name: 'Sub' }]); + expect(nodePlace(nodes, 'f0')?.stack).toEqual([]); + expect(nodePlace(nodes, 'o:f')?.stack).toEqual([{ id: 'o:s', name: 'Docs' }]); + expect(nodePlace(nodes, 'o:top')).toBeNull(); + expect(nodePlace(nodes, 'gone')).toBeNull(); + }); + + it('keeps stars by path, following a rename and dropping a delete', () => { + expect(toggleFavorite(['/A'], '/B')).toEqual(['/A', '/B']); + expect(toggleFavorite(['/A', '/B'], '/A')).toEqual(['/B']); + expect(moveFavorites(['/Docs', '/Docs/Sub', '/Docsx'], '/Docs', '/Papers')).toEqual(['/Papers', '/Papers/Sub', '/Docsx']); + expect(dropFavoritesUnder(['/Docs', '/Docs/Sub', '/Docsx'], '/Docs')).toEqual(['/Docsx']); + }); + + it('keeps the newest 20 recent files, once each, and prunes the gone', () => { + let recent = addRecentFile([], 'a', '1', 1); + recent = addRecentFile(recent, 'b', '2', 2); + recent = addRecentFile(recent, 'a', '1', 3); + expect(recent.map((r) => r.id)).toEqual(['1', '2']); + for (let i = 0; i < 30; i++) recent = addRecentFile(recent, `n${i}`, `x${i}`, 10 + i); + expect(recent).toHaveLength(RECENT_FILES_MAX); + expect(recent[0].id).toBe('x29'); + expect(pruneRecentFiles(recent, new Set(['x29', 'x28'])).map((r) => r.id)).toEqual(['x29', 'x28']); + }); +}); + +describe('files places store', () => { + beforeEach(() => useFilesPlacesStore.setState({ places: {} })); + + it('keeps each account to itself', () => { + const s = useFilesPlacesStore.getState(); + s.toggleFavorite('alice@a', '/Docs'); + s.addRecentFile('bob@b', 'x.pdf', 'f1'); + expect(useFilesPlacesStore.getState().places['alice@a']).toEqual({ favorites: ['/Docs'], recent: [] }); + expect(useFilesPlacesStore.getState().places['bob@b'].recent.map((r) => r.id)).toEqual(['f1']); + // A listing of bob's drive leaves alice's alone. + s.pruneRecentFiles('bob@b', new Set()); + expect(useFilesPlacesStore.getState().places['bob@b'].recent).toEqual([]); + expect(useFilesPlacesStore.getState().places['alice@a'].favorites).toEqual(['/Docs']); + }); + + it('changes nothing for a change that changes nothing', () => { + const before = useFilesPlacesStore.getState().places; + useFilesPlacesStore.getState().pruneRecentFiles('alice@a', new Set()); + useFilesPlacesStore.getState().toggleFavorite('', '/Docs'); + expect(useFilesPlacesStore.getState().places).toBe(before); + expect(NO_FILES_PLACES).toEqual({ favorites: [], recent: [] }); + }); + + // No RN renderer in the test env: check the screen's wiring instead. + it('is fed and shown by the Files tab', () => { + const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/FilesScreen.tsx'), 'utf8'); + // Opening, opening with and downloading a file make it recent. + expect(screen).toMatch(/const openPreview = useCallback\(\(row: FileRow\) => \{\s*setPreviewTarget\(row\);\s*recordRecent\(row\);/); + expect(screen).toMatch(/if \(!row\.blobId\) return;\s*recordRecent\(row\);/); + expect(screen).toMatch(/await downloadAttachment\([^)]*\);\s*recordRecent\(row\);/); + // A listing prunes; a rename, move or delete of a folder updates the stars. + expect(screen).toMatch(/pruneRecentFiles\(account, new Set\(withoutTrash\(nodes\)\.map\(\(n\) => n\.id\)\)\)/); + expect(screen).toMatch(/moveFavorites\(account, oldPath, oldPath\.slice\(0, oldPath\.lastIndexOf\('\/'\) \+ 1\) \+ name\)/); + expect(screen).toMatch(/dropStarsOf\(done\.map/); + expect(screen).toMatch(/dropStarsOf\(folderPaths\);/); + // The root lists them above its files, in both views. + expect(screen.match(/ListHeaderComponent=\{placesHeader\}/g)).toHaveLength(2); + expect(screen).toMatch(/const showPlaces = path\.length === 0 && !inTrash && !searchQuery\.trim\(\);/); + // A folder's actions star it. + expect(screen).toMatch(/starred \? t\('files\.unstar', 'Remove star'\) : t\('files\.star', 'Star'\)/); + }); +}); diff --git a/src/lib/files-places.ts b/src/lib/files-places.ts new file mode 100644 index 00000000..5f960129 --- /dev/null +++ b/src/lib/files-places.ts @@ -0,0 +1,111 @@ +// Starred folders and recent files of the Files tab, and the walks that open +// a folder by its path or a node where it is listed. Port of the webmail's +// file-store favorites/recentFiles (kept by path and by id, at most 20 +// recent files, 8 of them shown) and navigateByPath/revealNode. +import type { FileNode } from '../api/types'; +import { isFolder, listFolder } from '../api/files'; + +/** One folder of the stack the Files tab stands in. */ +export interface FolderStep { + id: string; + name: string; +} + +export interface RecentFile { + id: string; + name: string; + timestamp: number; +} + +export const RECENT_FILES_MAX = 20; +/** How many recent files the root of the Files tab lists (webmail RECENT_LIMIT). */ +export const RECENT_FILES_SHOWN = 8; + +/** `/A/B` for the stack A > B; `/` for the root. */ +export function folderPathOf(stack: FolderStep[]): string { + return `/${stack.map((s) => s.name).join('/')}`; +} + +/** Whether `path` is `base` or lies below it. */ +export function isUnderPath(path: string, base: string): boolean { + if (base === '/') return true; + return path === base || path.startsWith(`${base}/`); +} + +/** + * The folder stack a path names, walking the folders the Files tab lists + * from the root down; at each level a folder of the user's own drive wins + * over a shared one of the same name. Null when a part of it is missing. + */ +export function resolveFolderPath(nodes: FileNode[], path: string): FolderStep[] | null { + const names = path.split('/').filter(Boolean); + const stack: FolderStep[] = []; + let parentId: string | null = null; + for (const name of names) { + const matches: FileNode[] = listFolder(nodes, parentId).filter((n) => isFolder(n) && n.name === name); + const next: FileNode | undefined = matches.find((n) => !n.isShared) ?? matches[0]; + if (!next) return null; + stack.push({ id: next.id, name: next.name }); + parentId = next.id; + } + return stack; +} + +/** + * Where a node is listed: the folder stack down to the folder that holds it + * (empty at the root), the way the root listing reaches it. A node of a + * shared drive is reached through the shared root above it. Null when the + * node is gone or no listing reaches it. + */ +export function nodePlace(nodes: FileNode[], id: string): { stack: FolderStep[]; node: FileNode } | null { + const node = nodes.find((n) => n.id === id); + if (!node || node.discoverableOnly) return null; + const rootIds = new Set(listFolder(nodes, null).map((n) => n.id)); + if (rootIds.has(node.id)) return { stack: [], node }; + const byId = new Map(nodes.map((n) => [n.id, n])); + const stack: FolderStep[] = []; + let parentId = node.parentId ?? null; + for (let guard = 0; parentId !== null && guard < 1000; guard++) { + const parent = byId.get(parentId); + if (!parent || parent.discoverableOnly || !isFolder(parent)) return null; + stack.unshift({ id: parent.id, name: parent.name }); + if (rootIds.has(parent.id)) return { stack, node }; + parentId = parent.parentId ?? null; + } + return null; +} + +export function toggleFavorite(favorites: string[], path: string): string[] { + return favorites.includes(path) ? favorites.filter((f) => f !== path) : [...favorites, path]; +} + +/** Stars follow a folder that was renamed or moved, and those below it. */ +export function moveFavorites(favorites: string[], oldPath: string, newPath: string): string[] { + if (oldPath === '/' || oldPath === newPath) return favorites; + const next = favorites.map((f) => (isUnderPath(f, oldPath) ? newPath + f.slice(oldPath.length) : f)); + return next.some((f, i) => f !== favorites[i]) ? next : favorites; +} + +/** Stars of a deleted folder, and of those below it, go. */ +export function dropFavoritesUnder(favorites: string[], path: string): string[] { + if (path === '/') return favorites; + const next = favorites.filter((f) => !isUnderPath(f, path)); + return next.length === favorites.length ? favorites : next; +} + +/** The file goes to the top of the list, once, which keeps the newest 20. */ +export function addRecentFile(recent: RecentFile[], name: string, id: string, now = Date.now()): RecentFile[] { + return [{ id, name, timestamp: now }, ...recent.filter((r) => r.id !== id)].slice(0, RECENT_FILES_MAX); +} + +export function removeRecentFiles(recent: RecentFile[], ids: Iterable): RecentFile[] { + const gone = new Set(ids); + const next = recent.filter((r) => !gone.has(r.id)); + return next.length === recent.length ? recent : next; +} + +/** Recent files whose node is gone or in the trash drop off after a listing. */ +export function pruneRecentFiles(recent: RecentFile[], existingIds: Set): RecentFile[] { + const next = recent.filter((r) => existingIds.has(r.id)); + return next.length === recent.length ? recent : next; +} diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index a8d806ba..c17caa77 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -21,7 +21,7 @@ import { ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo, FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon, MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download, Search, - ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check, Eye, ExternalLink, FileSymlink, RotateCcw, + ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check, Eye, ExternalLink, FileSymlink, RotateCcw, Star, Clock, } from '../components/icons'; import { useFocusEffect } from '@react-navigation/native'; import { File, Paths } from 'expo-file-system'; @@ -85,6 +85,10 @@ import Dialog from '../components/Dialog'; import ShareSheet from '../components/files/ShareSheet'; import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal'; import { previewNeighbours } from '../lib/files-preview-neighbours'; +import { + RECENT_FILES_SHOWN, folderPathOf, nodePlace, resolveFolderPath, type FolderStep, +} from '../lib/files-places'; +import { NO_FILES_PLACES, useFilesPlacesStore } from '../stores/files-places-store'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; // A file row carries the display name alongside the rest of the node. @@ -266,6 +270,9 @@ export default function FilesScreen() { const setSetting = useSettingsStore((s) => s.updateSetting); const activeAccountId = useAuthStore((s) => s.activeAccountId); const session = useAuthStore((s) => s.session); + // Starred folders and recent files of the signed-in account (webmail + // file-store favorites / recentFiles). + const places = useFilesPlacesStore((s) => (activeAccountId ? s.places[activeAccountId] : undefined)) ?? NO_FILES_PLACES; const [viewOverride, setViewOverride] = useState(null); const viewMode: FilesViewMode = viewOverride ?? defaultViewMode; @@ -299,6 +306,7 @@ export default function FilesScreen() { const loadFiles = useCallback( async (mode: 'initial' | 'refresh' | 'quiet' = 'initial') => { const seq = ++loadSeqRef.current; + const account = useAuthStore.getState().activeAccountId; if (mode === 'refresh') setRefreshing(true); else if (mode === 'initial') setLoading(true); if (mode !== 'quiet') setError(null); @@ -306,7 +314,13 @@ export default function FilesScreen() { // One fetch across all accessible accounts: own files plus nodes other // principals shared with us (tagged isShared, ids namespaced). const nodes = await getAllFileNodesAcrossAccounts(); - if (seq === loadSeqRef.current) setAllNodes(nodes); + if (seq === loadSeqRef.current) { + setAllNodes(nodes); + // Recent files that are gone or in the trash drop off. + if (account && account === useAuthStore.getState().activeAccountId) { + useFilesPlacesStore.getState().pruneRecentFiles(account, new Set(withoutTrash(nodes).map((n) => n.id))); + } + } } catch (e) { // A quiet read keeps what is on screen. if (mode !== 'quiet' && seq === loadSeqRef.current) { @@ -451,10 +465,33 @@ export default function FilesScreen() { }); }, []); + // Opened and downloaded files go to the top of Recent (webmail addRecentFile). + const recordRecent = useCallback((row: FileRow) => { + const account = useAuthStore.getState().activeAccountId; + if (!account || row.trash) return; + useFilesPlacesStore.getState().addRecentFile(account, row.displayName, row.id); + }, []); + + // `/A/B` for a folder of the drive, `/` for the root; null when no listing + // reaches it. Stars are kept by path, as in the webmail. + const folderPathFor = useCallback((id: string | null): string | null => { + if (id === null) return '/'; + const place = nodePlace(driveNodes, id); + return place ? folderPathOf([...place.stack, { id: place.node.id, name: place.node.name }]) : null; + }, [driveNodes]); + + const toggleStar = useCallback((row: FileRow) => { + const account = useAuthStore.getState().activeAccountId; + const folderPath = folderPathFor(row.id); + if (!account || !folderPath || folderPath === '/') return; + useFilesPlacesStore.getState().toggleFavorite(account, folderPath); + }, [folderPathFor]); + // Hand the file to another app: the viewer for its type on Android, the // share sheet otherwise. const openFileWith = useCallback(async (row: FileRow) => { if (!row.blobId) return; + recordRecent(row); setBusyId(row.id); try { await shareAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId); @@ -463,12 +500,13 @@ export default function FilesScreen() { } finally { setBusyId(null); } - }, [t]); + }, [recordRecent, t]); // Every way into the preview: a tap, the row's actions, previous / next. const openPreview = useCallback((row: FileRow) => { setPreviewTarget(row); - }, []); + recordRecent(row); + }, [recordRecent]); // Tap on a file: the in-app preview for the types it shows (lib/file-preview), // the other app for the rest. @@ -482,12 +520,13 @@ export default function FilesScreen() { setBusyId(row.id); try { await downloadAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId); + recordRecent(row); } catch (e) { Alert.alert(t('files.download_error', 'Failed to download'), e instanceof Error ? e.message : String(e)); } finally { setBusyId(null); } - }, [t]); + }, [recordRecent, t]); // Batch download: one selected file goes straight to the share sheet; more // than one (or any folder) is bundled into a zip that mirrors the folder @@ -658,13 +697,19 @@ export default function FilesScreen() { const target = renameTarget; setRenameTarget(null); setRenameValue(''); + const oldPath = isFolder(target) ? folderPathFor(target.id) : null; try { await renameFileNode(target.id, name); + // Stars of the folder and below it follow the new name. + const account = useAuthStore.getState().activeAccountId; + if (account && oldPath && oldPath !== '/') { + useFilesPlacesStore.getState().moveFavorites(account, oldPath, oldPath.slice(0, oldPath.lastIndexOf('/') + 1) + name); + } await loadFiles('refresh'); } catch (e) { Alert.alert(t('files.rename_error', 'Failed to rename'), fileErrorMessage(e, t)); } - }, [renameTarget, renameValue, loadFiles, checkName, t]); + }, [renameTarget, renameValue, loadFiles, checkName, folderPathFor, t]); const duplicateFile = useCallback(async (row: FileRow) => { // A folder is copied with everything in it (webmail copyFileNode). @@ -691,15 +736,31 @@ export default function FilesScreen() { return; } setBusyId(row.id); + const oldPath = isFolder(row) ? folderPathFor(row.id) : null; + const destinationPath = folderPathFor(destinationId); try { await moveFileNode(row.id, destinationId); + // A moved folder keeps its stars, at its new path. + const account = useAuthStore.getState().activeAccountId; + if (account && oldPath && destinationPath) { + useFilesPlacesStore.getState().moveFavorites(account, oldPath, `${destinationPath === '/' ? '' : destinationPath}/${row.name}`); + } await loadFiles('refresh'); } catch (e) { Alert.alert(t('files.move_error', 'Failed to move'), fileErrorMessage(e, t)); } finally { setBusyId(null); } - }, [loadFiles, t]); + }, [loadFiles, folderPathFor, t]); + + // Stars of deleted folders, and of those below them, go (webmail + // deleteNode / trashResources); recent files drop off with the next + // listing. + const dropStarsOf = useCallback((paths: Array) => { + const account = useAuthStore.getState().activeAccountId; + if (!account) return; + for (const p of paths) if (p && p !== '/') useFilesPlacesStore.getState().dropFavoritesUnder(account, p); + }, []); const cancelUpload = useCallback(() => { uploadAbortRef.current?.abort(); @@ -799,9 +860,11 @@ export default function FilesScreen() { const trashRows = useCallback(async (rows: FileRow[]) => { clearSelection(); const listing = allNodes; + const folderPaths = new Map(rows.filter((r) => isFolder(r)).map((r) => [r.id, folderPathFor(r.id)])); try { const moved = await moveToTrash(listing, rows); const { done, refused } = moved; + dropStarsOf(done.map((item) => folderPaths.get(item.node.id) ?? null)); await loadFiles('refresh'); if (refused.length > 0) { Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(refused[0], t)); @@ -817,7 +880,7 @@ export default function FilesScreen() { else Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t)); await loadFiles('refresh'); } - }, [allNodes, clearSelection, loadFiles, undoTrash, t]); + }, [allNodes, clearSelection, loadFiles, undoTrash, folderPathFor, dropStarsOf, t]); const requestDelete = useCallback((rows: FileRow[]) => { if (rows.length === 0) return; @@ -836,7 +899,11 @@ export default function FilesScreen() { // the trash only what is still there goes (see purgeFromTrash). let count = targets.length; if (fromTrash.length > 0) count = await purgeFromTrash(fromTrash); - else await deleteFileNodes(targets.map((r) => r.id)); + else { + const folderPaths = targets.filter((r) => isFolder(r)).map((r) => folderPathFor(r.id)); + await deleteFileNodes(targets.map((r) => r.id)); + dropStarsOf(folderPaths); + } if (count > 0) { toast.success(t('files.deleted_forever', '{count, plural, one {Deleted permanently} other {# items deleted permanently}}', { count, @@ -846,7 +913,7 @@ export default function FilesScreen() { Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t)); } await loadFiles('refresh'); - }, [confirmDelete, clearSelection, loadFiles, t]); + }, [confirmDelete, clearSelection, loadFiles, folderPathFor, dropStarsOf, t]); const restoreRows = useCallback(async (rows: FileRow[]) => { const items = rows.flatMap((r) => (r.trash ? [r.trash] : [])); @@ -1227,6 +1294,121 @@ export default function FilesScreen() { ); }; + // Starred folders and recent files, listed above the root's files (the + // webmail's sidebar sections). A star whose folder is gone is not shown. + const showPlaces = path.length === 0 && !inTrash && !searchQuery.trim(); + const starredFolders = useMemo( + () => places.favorites.flatMap((p) => { + const stack = resolveFolderPath(driveNodes, p); + return stack && stack.length > 0 ? [{ path: p, stack }] : []; + }), + [places.favorites, driveNodes], + ); + const recentFiles = places.recent.slice(0, RECENT_FILES_SHOWN); + const openFolderStack = (stack: FolderStep[]) => { + setInTrash(false); + setPath(stack); + setSearchQuery(''); + clearSelection(); + }; + // A recent file can live in any folder: open that folder, then the file. + const openRecent = (id: string, reveal = false) => { + const place = nodePlace(driveNodes, id); + if (!place || isFolder(place.node)) { + toast.error(t('deep_link.file_not_found', 'This file is no longer available.')); + return; + } + openFolderStack(place.stack); + if (!reveal && !isSymlink(place.node)) openFile({ ...place.node, displayName: place.node.name }); + }; + const recentActions = (id: string, name: string) => { + Alert.alert(name, undefined, [ + { text: t('files.open', 'Open'), onPress: () => openRecent(id) }, + { text: t('files.show_in_folder', 'Show in folder'), onPress: () => openRecent(id, true) }, + { + text: t('files.remove_from_recent', 'Remove from recent'), + onPress: () => { + if (activeAccountId) useFilesPlacesStore.getState().removeRecentFiles(activeAccountId, [id]); + }, + }, + { text: t('files.cancel', 'Cancel'), style: 'cancel' }, + ]); + }; + const starredActions = (folderPath: string, name: string) => { + Alert.alert(name, undefined, [ + { + text: t('files.unstar', 'Remove star'), + onPress: () => { + if (activeAccountId) useFilesPlacesStore.getState().toggleFavorite(activeAccountId, folderPath); + }, + }, + { text: t('files.cancel', 'Cancel'), style: 'cancel' }, + ]); + }; + const placesHeader = showPlaces && (starredFolders.length > 0 || recentFiles.length > 0) ? ( + + {starredFolders.length > 0 ? ( + {t('files.favorites', 'Starred')} + ) : null} + {starredFolders.map(({ path: folderPath, stack }) => { + const name = stack[stack.length - 1].name; + return ( + openFolderStack(stack)} + onLongPress={() => starredActions(folderPath, name)} + delayLongPress={300} + style={({ pressed }) => [styles.fileRow, pressed && { backgroundColor: c.surfaceHover }]} + accessibilityRole="button" + > + + {name} + starredActions(folderPath, name)} + hitSlop={8} + style={styles.rowMore} + accessibilityRole="button" + accessibilityLabel={t('files.context_menu', 'Actions')} + > + + + + ); + })} + {recentFiles.length > 0 ? ( + {t('files.recent', 'Recent')} + ) : null} + {recentFiles.map((entry) => { + const Icon = pickFileIcon(entry.name); + return ( + openRecent(entry.id)} + onLongPress={() => recentActions(entry.id, entry.name)} + delayLongPress={300} + style={({ pressed }) => [styles.fileRow, pressed && { backgroundColor: c.surfaceHover }]} + accessibilityRole="button" + > + {showIcons ? ( + + ) : } + {entry.name} + recentActions(entry.id, entry.name)} + hitSlop={8} + style={styles.rowMore} + accessibilityRole="button" + accessibilityLabel={t('files.context_menu', 'Actions')} + > + + + + ); + })} + {rootLabel} + + ) : null; + let body: React.ReactNode; if (loading && !refreshing) { body = ( @@ -1281,6 +1463,7 @@ export default function FilesScreen() { contentContainerStyle={styles.gridContent} onLayout={(e) => setGridWidth(Math.round(e.nativeEvent.layout.width))} renderItem={({ item }) => {renderGridCell(item)}} + ListHeaderComponent={placesHeader} keyboardShouldPersistTaps="handled" refreshControl={ @@ -1294,6 +1477,7 @@ export default function FilesScreen() { key="list" keyExtractor={(item) => item.id} renderItem={({ item }) => renderRow(item)} + ListHeaderComponent={placesHeader} keyboardShouldPersistTaps="handled" refreshControl={ @@ -1405,6 +1589,13 @@ export default function FilesScreen() { setActionsTarget(null); void restoreRows([r]); }} + starred={actionsTarget && isFolder(actionsTarget) && !actionsTarget.trash + ? places.favorites.includes(folderPathFor(actionsTarget.id) ?? '') + : null} + onToggleStar={(r) => { + setActionsTarget(null); + toggleStar(r); + }} /> void; /** For a row of the trash: put it back where it came from. */ onRestore: (r: FileRow) => void; + /** For a folder: whether it is starred; null hides Star. */ + starred: boolean | null; + onToggleStar: (r: FileRow) => void; } function ActionsSheet({ target, access, sharingEnabled, t, onClose, onPreview, onOpenWith, onDownload, onShare, onMove, onDuplicate, onRename, onDelete, - onRestore, + onRestore, starred, onToggleStar, }: ActionsSheetProps) { // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); @@ -1659,6 +1853,12 @@ function ActionsSheet({ {t('files.download', 'Download')} ) : null} + {starred !== null ? ( + onToggleStar(target)}> + + {starred ? t('files.unstar', 'Remove star') : t('files.star', 'Star')} + + ) : null} {canShare ? ( onShare(target)}> @@ -1997,6 +2197,20 @@ function makeStyles(c: ThemePalette) { fileInfo: { flex: 1, }, + places: { + paddingTop: spacing.xs, + }, + placesTitle: { + fontSize: typography.caption.fontSize, + fontWeight: '600' as const, + color: c.textMuted, + paddingHorizontal: spacing.md, + paddingTop: spacing.md, + paddingBottom: spacing.xs, + }, + placeName: { + flex: 1, + }, fileNameRow: { flexDirection: 'row', alignItems: 'center', diff --git a/src/stores/files-places-store.ts b/src/stores/files-places-store.ts new file mode 100644 index 00000000..7efc4966 --- /dev/null +++ b/src/stores/files-places-store.ts @@ -0,0 +1,68 @@ +// Starred folders and recent files of the Files tab, kept on the device per +// signed-in account: the webmail keeps them in localStorage, and ids and +// paths of one account mean nothing in another. +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { createPersistStorage } from './persist-storage'; +import { + addRecentFile, + dropFavoritesUnder, + moveFavorites, + pruneRecentFiles, + removeRecentFiles, + toggleFavorite, + type RecentFile, +} from '../lib/files-places'; + +export interface FilesPlaces { + favorites: string[]; + recent: RecentFile[]; +} + +/** What an account without starred or recent files has; one shared object. */ +export const NO_FILES_PLACES: FilesPlaces = Object.freeze({ favorites: [], recent: [] }) as FilesPlaces; + +interface FilesPlacesState { + /** By signed-in account id. */ + places: Record; + toggleFavorite: (account: string, path: string) => void; + moveFavorites: (account: string, oldPath: string, newPath: string) => void; + dropFavoritesUnder: (account: string, path: string) => void; + addRecentFile: (account: string, name: string, id: string) => void; + removeRecentFiles: (account: string, ids: Iterable) => void; + pruneRecentFiles: (account: string, existingIds: Set) => void; +} + +export const useFilesPlacesStore = create()( + persist( + (set, get) => { + const update = (account: string, change: (p: FilesPlaces) => FilesPlaces) => { + if (!account) return; + const current = get().places[account] ?? NO_FILES_PLACES; + const next = change(current); + if (next.favorites === current.favorites && next.recent === current.recent) return; + set({ places: { ...get().places, [account]: next } }); + }; + return { + places: {}, + toggleFavorite: (account, path) => + update(account, (p) => ({ ...p, favorites: toggleFavorite(p.favorites, path) })), + moveFavorites: (account, oldPath, newPath) => + update(account, (p) => ({ ...p, favorites: moveFavorites(p.favorites, oldPath, newPath) })), + dropFavoritesUnder: (account, path) => + update(account, (p) => ({ ...p, favorites: dropFavoritesUnder(p.favorites, path) })), + addRecentFile: (account, name, id) => + update(account, (p) => ({ ...p, recent: addRecentFile(p.recent, name, id) })), + removeRecentFiles: (account, ids) => + update(account, (p) => ({ ...p, recent: removeRecentFiles(p.recent, ids) })), + pruneRecentFiles: (account, existingIds) => + update(account, (p) => ({ ...p, recent: pruneRecentFiles(p.recent, existingIds) })), + }; + }, + { + name: 'files-places', + storage: createPersistStorage(), + partialize: (state) => ({ places: state.places }), + }, + ), +); From d684b00e9b92e808adef89e878a5945cd6c68b65 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:29:46 +0100 Subject: [PATCH 06/14] feat: open a files link on the folder and file it names --- src/navigation/__tests__/files-link.test.ts | 48 +++++++++++++++++++++ src/navigation/linking.ts | 29 +++++++++++-- src/navigation/pending-files-link.ts | 37 ++++++++++++++++ src/screens/FilesScreen.tsx | 47 ++++++++++++++++++++ 4 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 src/navigation/__tests__/files-link.test.ts create mode 100644 src/navigation/pending-files-link.ts diff --git a/src/navigation/__tests__/files-link.test.ts b/src/navigation/__tests__/files-link.test.ts new file mode 100644 index 00000000..835dee2c --- /dev/null +++ b/src/navigation/__tests__/files-link.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { handleDeepLink, parseDeepLink } from '../linking'; +import { usePendingFilesLink } from '../pending-files-link'; + +// `/files/` and `?preview=` links opened only the Files tab (file 10 +// "deep links" on the Files row, 07 "Deep link /files/ and ?preview= +// not handled"). Webmail lib/deep-links.ts parseFilesPath. + +const nav = () => ({ isReady: () => true, navigate: vi.fn() }); + +describe('files deep links', () => { + it('parses the folder path, the file to preview and a shared node', () => { + expect(parseDeepLink('bulwarkmobile://files')).toEqual({ kind: 'files' }); + expect(parseDeepLink('bulwarkmobile://files/Docs/Q%203')) + .toEqual({ kind: 'files', path: '/Docs/Q 3' }); + expect(parseDeepLink('https://mail.example.com/de/files/Docs?preview=a%20b.pdf')) + .toEqual({ kind: 'files', path: '/Docs', preview: 'a b.pdf' }); + expect(parseDeepLink('https://mail.example.com/files?preview=top.txt')) + .toEqual({ kind: 'files', path: '/', preview: 'top.txt' }); + expect(parseDeepLink('bulwarkmobile://files?shared=acc-2%3An1&preview=x.txt')) + .toEqual({ kind: 'files', path: '/', shared: 'acc-2:n1', preview: 'x.txt' }); + }); + + it('parks the link for the Files tab', async () => { + const navigation = nav(); + await handleDeepLink(parseDeepLink('bulwarkmobile://files/Docs?preview=a.pdf')!, { + navigation: navigation as never, resolveThreadId: async () => null, + }); + expect(usePendingFilesLink.getState().consume()).toEqual({ path: '/Docs', preview: 'a.pdf' }); + expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Files' }); + + await handleDeepLink(parseDeepLink('bulwarkmobile://files')!, { + navigation: navigation as never, resolveThreadId: async () => null, + }); + expect(usePendingFilesLink.getState().consume()).toBeNull(); + }); + + // No RN renderer in the test env: check the screen's wiring instead. + it('is followed by the Files tab once the drive is listed', () => { + const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/FilesScreen.tsx'), 'utf8'); + expect(screen).toMatch(/if \(!pendingFilesLink \|\| loading \|\| !driveRef\.current\?\.account\) return;\s*const link = usePendingFilesLink\.getState\(\)\.consume\(\);/); + expect(screen).toMatch(/stack = resolveFolderPath\(driveNodes\.filter\(\(n\) => !n\.isShared\), link\.path\);/); + expect(screen).toMatch(/listFolder\(driveNodes, parentId\)\.find\(\(n\) => n\.name === link\.preview && !isFolder\(n\)\)/); + expect(screen).toMatch(/if \(row && !isSymlink\(row\) && canPreviewInApp\(row\)\) openPreview\(row\);/); + }); +}); diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index 28e4fa5d..ddadeac4 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -16,6 +16,7 @@ import { } from './pending-calendar-date'; import { setPendingMailSearch } from './pending-mail-search'; import { setPendingMailFolder } from './pending-mail-folder'; +import { setPendingFilesLink } from './pending-files-link'; import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link'; import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth'; import { MAX_ACCOUNTS } from '../lib/account-utils'; @@ -52,7 +53,10 @@ export type DeepLink = // `/contacts/new`, with `?email=`/`?addEmail=` and `?name=`/`?addName=`. | { kind: 'contact-new'; email?: string; name?: string } | { kind: 'contacts' } - | { kind: 'files' } + // `path`: a folder (`/files//`); `preview`: a file in it to + // open (`?preview=`); `shared`: a node of a drive shared with the user + // (`?shared=:`). None of them: just the Files tab. + | { kind: 'files'; path?: string; preview?: string; shared?: string } | { kind: 'settings'; tab?: string } | { kind: 'compose'; to: EmailAddress[]; cc: EmailAddress[]; bcc?: EmailAddress[]; subject?: string; body?: string }; @@ -191,8 +195,19 @@ export function parseDeepLink(url: string): DeepLink | null { } return { kind: 'contacts' }; } - case 'files': - return { kind: 'files' }; + case 'files': { + // Webmail parseFilesPath. + const parts = segments.slice(1).map(decodeSegment).filter(Boolean); + const preview = search.get('preview') || undefined; + const shared = search.get('shared') || undefined; + if (parts.length === 0 && !preview && !shared) return { kind: 'files' }; + return { + kind: 'files', + path: `/${parts.join('/')}`, + ...(preview ? { preview } : {}), + ...(shared ? { shared } : {}), + }; + } case 'settings': return { kind: 'settings', tab: kind ? decodeSegment(kind) : undefined }; case 'compose': { @@ -355,6 +370,14 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr navigation.navigate('MainTabs', { screen: 'Contacts' } as never); return true; case 'files': + // The Files tab walks to the folder once it has listed the drive. + if (link.path || link.preview || link.shared) { + setPendingFilesLink({ + path: link.path ?? '/', + ...(link.preview ? { preview: link.preview } : {}), + ...(link.shared ? { shared: link.shared } : {}), + }); + } navigation.navigate('MainTabs', { screen: 'Files' } as never); return true; case 'settings': diff --git a/src/navigation/pending-files-link.ts b/src/navigation/pending-files-link.ts new file mode 100644 index 00000000..2d967283 --- /dev/null +++ b/src/navigation/pending-files-link.ts @@ -0,0 +1,37 @@ +import { create } from 'zustand'; + +/** A `/files/[?preview=]` link (webmail FilesDeepLink). */ +export interface FilesLink { + /** Folder path in the drive, leading-slashed (`/` for the root). */ + path: string; + /** File name to preview once the folder is open. */ + preview?: string; + /** + * A node of a drive shared with the user, by the id the Files tab lists it + * under (`:`): it has no path under the user's own root. + */ + shared?: string; +} + +// A files link arrives before the Files tab is mounted or has listed the +// drive. It is parked here and consumed by FilesScreen once it can walk the +// drive, like pending-mail-folder. +interface PendingFilesLinkState { + link: FilesLink | null; + set: (link: FilesLink | null) => void; + consume: () => FilesLink | null; +} + +export const usePendingFilesLink = create((set, get) => ({ + link: null, + set: (link) => set({ link }), + consume: () => { + const link = get().link; + if (link) set({ link: null }); + return link; + }, +})); + +export function setPendingFilesLink(link: FilesLink | null): void { + usePendingFilesLink.getState().set(link); +} diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index c17caa77..bdff6dd9 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -89,6 +89,7 @@ import { RECENT_FILES_SHOWN, folderPathOf, nodePlace, resolveFolderPath, type FolderStep, } from '../lib/files-places'; import { NO_FILES_PLACES, useFilesPlacesStore } from '../stores/files-places-store'; +import { usePendingFilesLink, type FilesLink } from '../navigation/pending-files-link'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; // A file row carries the display name alongside the rest of the node. @@ -1409,6 +1410,52 @@ export default function FilesScreen() { ) : null; + // A `/files/?preview=` link (webmail files-app): once the + // drive is listed, walk to the folder and open the file there. A link into + // a shared drive names the node by id (`?shared=`). + const applyFilesLink = (link: FilesLink) => { + let stack: FolderStep[] | null = null; + let fileNode: FileNode | undefined; + if (link.shared) { + const place = nodePlace(driveNodes, link.shared); + if (place && isFolder(place.node)) stack = [...place.stack, { id: place.node.id, name: place.node.name }]; + else if (place) { + stack = place.stack; + fileNode = place.node; + } + } else if (link.path === `/${TRASH_FOLDER_NAME}`) { + setPath([]); + setInTrash(true); + setSearchQuery(''); + clearSelection(); + return; + } else { + stack = resolveFolderPath(driveNodes.filter((n) => !n.isShared), link.path); + } + if (!stack) { + toast.error(link.preview + ? t('deep_link.file_not_found', 'This file is no longer available.') + : t('deep_link.folder_not_found', 'This folder is no longer available.')); + return; + } + openFolderStack(stack); + if (!link.preview && !fileNode) return; + const parentId = stack.length > 0 ? stack[stack.length - 1].id : null; + const target = fileNode ?? listFolder(driveNodes, parentId).find((n) => n.name === link.preview && !isFolder(n)); + const row = target ? { ...target, displayName: target.name } : null; + if (row && !isSymlink(row) && canPreviewInApp(row)) openPreview(row); + else toast.error(t('deep_link.file_not_found', 'This file is no longer available.')); + }; + const applyFilesLinkRef = useRef(applyFilesLink); + applyFilesLinkRef.current = applyFilesLink; + const pendingFilesLink = usePendingFilesLink((s) => s.link); + useEffect(() => { + // Wait for the listing: the walk needs the drive's folders. + if (!pendingFilesLink || loading || !driveRef.current?.account) return; + const link = usePendingFilesLink.getState().consume(); + if (link) applyFilesLinkRef.current(link); + }, [pendingFilesLink, loading]); + let body: React.ReactNode; if (loading && !refreshing) { body = ( From 96b163a11796acafaca4d3e5967be895ffb3660d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:36:52 +0100 Subject: [PATCH 07/14] feat: share an address book with other people --- src/api/__tests__/contacts.test.ts | 25 ++ src/api/contacts.ts | 30 +- .../__tests__/address-book-share.test.ts | 32 ++ .../__tests__/calendar-i18n.test.ts | 2 + .../calendar/CalendarShareSheet.tsx | 348 +---------------- .../contacts/AddressBookShareSheet.tsx | 39 ++ src/components/settings/ContactsSettings.tsx | 28 +- .../sharing/ShareCollectionSheet.tsx | 353 ++++++++++++++++++ src/stores/__tests__/contacts-store.test.ts | 19 + src/stores/contacts-store.ts | 23 +- 10 files changed, 566 insertions(+), 333 deletions(-) create mode 100644 src/components/__tests__/address-book-share.test.ts create mode 100644 src/components/contacts/AddressBookShareSheet.tsx create mode 100644 src/components/sharing/ShareCollectionSheet.tsx diff --git a/src/api/__tests__/contacts.test.ts b/src/api/__tests__/contacts.test.ts index c812c3c7..bcebb3bc 100644 --- a/src/api/__tests__/contacts.test.ts +++ b/src/api/__tests__/contacts.test.ts @@ -34,6 +34,7 @@ import { updateAddressBook, setDefaultAddressBook, deleteAddressBook, + setAddressBookShare, } from '../contacts'; const mockRequest = jmapClient.request as ReturnType; @@ -72,6 +73,30 @@ describe('contacts operations', () => { }); }); + describe('setAddressBookShare', () => { + const read = { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false }; + + it('patches one principal on the book', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['AddressBook/set', { updated: { ab: null } }, '0']] }); + await setAddressBookShare('ab', 'carol', read, 'acc-team'); + expect(mockRequest.mock.calls[0][0]).toEqual([['AddressBook/set', { + accountId: 'acc-team', + update: { ab: { 'shareWith/carol': read } }, + }, '0']]); + await setAddressBookShare('ab', 'carol', null); + expect(mockRequest.mock.calls[1][0][0][1]).toEqual({ accountId: 'acc-1', update: { ab: { 'shareWith/carol': null } } }); + }); + + it('throws when the server refuses or does not confirm', async () => { + mockRequest.mockResolvedValueOnce({ + methodResponses: [['AddressBook/set', { notUpdated: { ab: { type: 'forbidden', description: 'no' } } }, '0']], + }); + await expect(setAddressBookShare('ab', 'carol', read)).rejects.toThrow('no'); + mockRequest.mockResolvedValueOnce({ methodResponses: [['AddressBook/set', { updated: {} }, '0']] }); + await expect(setAddressBookShare('ab', 'carol', read)).rejects.toThrow('Server did not confirm the share update'); + }); + }); + describe('getAddressBooks', () => { it('should fetch address books with an explicit property list', async () => { const books = [{ id: 'ab-1', name: 'Personal' }]; diff --git a/src/api/contacts.ts b/src/api/contacts.ts index 826a1748..aff203d6 100644 --- a/src/api/contacts.ts +++ b/src/api/contacts.ts @@ -1,7 +1,7 @@ import { jmapClient } from './jmap-client'; import { JMAPSetError } from './jmap-result'; import { CAPABILITIES } from './types'; -import type { ContactCard, ContactMedia, AddressBook } from './types'; +import type { ContactCard, ContactMedia, AddressBook, AddressBookRights } from './types'; import { generateUUID } from '../lib/uuid'; import { contactFromWire, contactToWire } from '../lib/contact-wire'; import { accountSupports } from '../lib/account-capability'; @@ -379,6 +379,34 @@ export async function updateAddressBook( if (err) throw new Error(setErrorMessage(err, 'Failed to update address book')); } +/** + * Give a principal rights on an address book, or take them away with null + * (RFC 9610 `shareWith`, patched per principal like the webmail's + * setAddressBookShare). Throws when the server refuses or doesn't confirm + * the update. + */ +export async function setAddressBookShare( + addressBookId: string, + principalId: string, + rights: AddressBookRights | null, + accountId?: string, +): Promise { + const account = accountId || getContactsAccountId(); + const res = await jmapClient.request( + [['AddressBook/set', { + accountId: account, + update: { [addressBookId]: { [`shareWith/${principalId}`]: rights } }, + }, '0']], + USING, + ); + const result = methodResult<{ updated?: Record | null; notUpdated?: Record }>(res); + const err = result.notUpdated?.[addressBookId]; + if (err) throw setError(err, 'Failed to update address book share'); + if (!result.updated || !(addressBookId in result.updated)) { + throw new Error('Server did not confirm the share update'); + } +} + /** * Mark an address book as the account default. `isDefault` is read-only in * AddressBook/set - the default is changed via the `onSuccessSetIsDefault` diff --git a/src/components/__tests__/address-book-share.test.ts b/src/components/__tests__/address-book-share.test.ts new file mode 100644 index 00000000..ff5906a6 --- /dev/null +++ b/src/components/__tests__/address-book-share.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Address books could not be shared on the phone (file 10 "address book +// sharing", 06 "Address book share / visibility management"). Webmail +// ShareCollectionDialog with kind "addressBook", offered for own books the +// user may share. No RN renderer in the test env: this checks the wiring. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); + +describe('address book sharing', () => { + it('offers the webmail presets, without free/busy', () => { + const sheet = read('components/contacts/AddressBookShareSheet.tsx'); + expect(sheet).toMatch(/read: \{ mayRead: true, mayWrite: false, mayShare: false, mayDelete: false \}/); + expect(sheet).toMatch(/readWrite: \{ mayRead: true, mayWrite: true, mayShare: false, mayDelete: false \}/); + expect(sheet).toMatch(/manager: \{ mayRead: true, mayWrite: true, mayShare: true, mayDelete: true \}/); + expect(sheet).not.toMatch(/freeBusy/); + expect(sheet).toMatch(/addRights=\{ADDRESS_BOOK_PRESETS\.read\}/); + }); + + it('is opened from the address book settings for own books the user may share', () => { + const settings = read('components/settings/ContactsSettings.tsx'); + expect(settings).toMatch(/\{!book\.isShared && book\.myRights\?\.mayShare \? \(\s* setShareTarget\(book\)\}/); + expect(settings).toMatch(/ { + expect(read('components/calendar/CalendarShareSheet.tsx')).toMatch(/ = { 'components/calendar/WeekView.tsx': [], 'components/calendar/CalendarEditSheet.tsx': [], 'components/calendar/CalendarShareSheet.tsx': [], + 'components/sharing/ShareCollectionSheet.tsx': [], + 'components/contacts/AddressBookShareSheet.tsx': [], 'components/calendar/CalendarSidebarDrawer.tsx': [], 'components/calendar/EventDetailSheet.tsx': [], // The meeting link placeholder shows an example URL. diff --git a/src/components/calendar/CalendarShareSheet.tsx b/src/components/calendar/CalendarShareSheet.tsx index f8b8bf89..e7051989 100644 --- a/src/components/calendar/CalendarShareSheet.tsx +++ b/src/components/calendar/CalendarShareSheet.tsx @@ -1,30 +1,12 @@ import React from 'react'; -import { - ActivityIndicator, - Alert, - Modal, - Pressable, - ScrollView, - StyleSheet, - Text, - TextInput, - TouchableWithoutFeedback, - View, -} from '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 type { Calendar, CalendarRights } from '../../api/types'; import { useLocaleStore } from '../../stores/locale-store'; -import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; -import { useSheetInsets } from '../../lib/use-layout'; +import { ShareCollectionSheet, type SharePreset } from '../sharing/ShareCollectionSheet'; type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager'; // Same presets the webmail's share-collection dialog offers for calendars. -const CALENDAR_PRESETS: Record = { +export const CALENDAR_PRESETS: Record = { freeBusy: { mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false, @@ -43,323 +25,29 @@ const CALENDAR_PRESETS: Record = { }, }; -// The webmail's sharing presets (same rights, same names). -const PRESET_LABEL_KEYS: Record = { - freeBusy: ['sharing.preset.freeBusy', 'Free/busy only'], - read: ['sharing.preset.read', 'Read only'], - readWrite: ['sharing.preset.readWrite', 'Read & write'], - manager: ['sharing.preset.manager', 'Manager'], -}; - -const PRESET_ORDER: RolePreset[] = ['freeBusy', 'read', 'readWrite', 'manager']; - -function detectPreset(rights: CalendarRights): RolePreset | 'custom' { - for (const preset of PRESET_ORDER) { - const expected = CALENDAR_PRESETS[preset]; - const keys = Object.keys(expected) as (keyof CalendarRights)[]; - if (keys.every((k) => !!expected[k] === !!rights[k])) return preset; - } - return 'custom'; -} - interface CalendarShareSheetProps { calendar: Calendar | null; onShare: (calendarId: string, principalId: string, rights: CalendarRights | null) => Promise; onClose: () => void; } -// JMAP sharing for an owned calendar: pick a principal, choose a role. The -// principal list comes from the same Principal/query the Files share sheet -// uses. +// JMAP sharing for an owned calendar: pick a principal, choose a role. 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); - - const [principals, setPrincipals] = React.useState([]); - const [directory, setDirectory] = React.useState>({ status: 'ok' }); - const [loading, setLoading] = React.useState(false); - const [shares, setShares] = React.useState>({}); - const [search, setSearch] = React.useState(''); - const [savingId, setSavingId] = React.useState(null); - - React.useEffect(() => { - if (!calendar) return undefined; - let cancelled = false; - setShares(calendar.shareWith ?? {}); - setSearch(''); - setLoading(true); - void listPrincipals().then(({ principals: list, ...listing }) => { - if (cancelled) return; - setPrincipals(list); - setDirectory(listing); - setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [calendar]); - - const principalsById = React.useMemo(() => { - const map = new Map(); - for (const p of principals) map.set(p.id, p); - return map; - }, [principals]); - - const sharedEntries = React.useMemo( - () => Object.entries(shares).filter(([, rights]) => rights != null), - [shares], - ); - - 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 - .filter((p) => p.id !== self && shares[p.id] == null) - .filter((p) => - !q || - p.name?.toLowerCase().includes(q) || - p.email?.toLowerCase().includes(q) || - p.description?.toLowerCase().includes(q)) - .slice(0, 25); - }, [principals, shares, search]); - - const applyShare = async (principalId: string, rights: CalendarRights | null) => { - if (!calendar || savingId) return; - setSavingId(principalId); - try { - await onShare(calendar.id, principalId, rights); - setShares((prev) => { - const next = { ...prev }; - if (rights == null) delete next[principalId]; - else next[principalId] = rights; - return next; - }); - } catch (e) { - Alert.alert( - t('calendar.share.error', 'Failed to update sharing'), - e instanceof Error ? e.message : String(e), - ); - } finally { - setSavingId(null); - } - }; - - if (!calendar) return null; - - // The list below is partial or empty for a reason the user can't see - // otherwise; "Sharing is not available on this server." would be wrong. - const notice = loading ? null : directoryNotice(directory); - const noticeText = - notice === 'failed' ? t('sharing.directory_failed', "Couldn't load the list of users and groups.") - : notice === 'restricted' - ? t( - 'sharing.directory_restricted', - "Your server doesn't allow browsing its user directory, so only your groups and people who have shared with you are listed. An administrator can turn on directory queries in Stalwart's sharing settings.", - ) - : notice === 'not_browsable' - ? t('sharing.directory_not_browsable', "Your server doesn't let you browse its user directory.") - : null; - - const renderPrincipalLabel = (principalId: string) => { - const p = principalsById.get(principalId); - // Stalwart 1.0 with directory queries off hides every principal the user - // has no relationship with, so a sharee may have no name. - const unresolved = !p && !loading; - return ( - - - {p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)} - - {p?.email || unresolved ? ( - {p?.email || principalId} - ) : null} - - ); - }; - + // The webmail's sharing presets (same rights, same names). + const presets = React.useMemo[]>(() => [ + { key: 'freeBusy', label: t('sharing.preset.freeBusy', 'Free/busy only'), rights: CALENDAR_PRESETS.freeBusy }, + { key: 'read', label: t('sharing.preset.read', 'Read only'), rights: CALENDAR_PRESETS.read }, + { key: 'readWrite', label: t('sharing.preset.readWrite', 'Read & write'), rights: CALENDAR_PRESETS.readWrite }, + { key: 'manager', label: t('sharing.preset.manager', 'Manager'), rights: CALENDAR_PRESETS.manager }, + ], [t]); return ( - - - - - - - - - {t('sharing.title', 'Share "{name}"', { name: calendar.name })} - - - - - {sharedEntries.length > 0 ? ( - - {t('calendar.share.shared_with', 'Shared with')} - {sharedEntries.map(([principalId, rights]) => { - const preset = detectPreset(rights); - const busy = savingId === principalId; - return ( - - {renderPrincipalLabel(principalId)} - - {PRESET_ORDER.map((p) => ( - void applyShare(principalId, CALENDAR_PRESETS[p])} - disabled={busy} - style={[styles.chip, preset === p && styles.chipActive]} - accessibilityRole="button" - accessibilityState={{ selected: preset === p, disabled: busy }} - > - - {t(PRESET_LABEL_KEYS[p][0], PRESET_LABEL_KEYS[p][1])} - - - ))} - {preset === 'custom' ? ( - {t('sharing.preset.custom', 'Custom')} - ) : null} - void applyShare(principalId, null)} - disabled={busy} - hitSlop={8} - style={styles.removeBtn} - accessibilityRole="button" - accessibilityLabel={t('sharing.remove', 'Remove access')} - > - {busy ? ( - - ) : ( - - )} - - - - ); - })} - - ) : null} - - - {t('calendar.share.add_people', 'Add people')} - - {noticeText ? ( - {noticeText} - ) : null} - {loading ? ( - - ) : candidates.length === 0 ? ( - notice && !search.trim() ? null : ( - - {principals.length === 0 && !notice - ? t('calendar.share.no_principals', 'Sharing is not available on this server.') - : t('calendar.share.no_matches', 'No matches')} - - ) - ) : ( - candidates.map((p) => ( - void applyShare(p.id, CALENDAR_PRESETS.read)} - disabled={!!savingId} - style={({ pressed }) => [styles.candidateRow, pressed && styles.rowPressed]} - > - {renderPrincipalLabel(p.id)} - {savingId === p.id ? ( - - ) : ( - - )} - - )) - )} - - - - - {t('common.done', 'Done')} - - - - - - + ); } - -function makeStyles(c: ThemePalette) { - return StyleSheet.create({ - backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, - sheet: { - backgroundColor: c.background, - borderTopLeftRadius: radius.xl, - borderTopRightRadius: radius.xl, - padding: spacing.lg, - paddingBottom: spacing.xxl, - maxHeight: '85%', - }, - titleRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.md }, - title: { ...typography.h3, color: c.text, flex: 1 }, - scroll: { flexGrow: 0 }, - section: { marginBottom: spacing.lg, gap: spacing.sm }, - sectionLabel: { ...typography.captionMedium, color: c.textSecondary }, - shareRow: { - gap: spacing.xs, - paddingVertical: spacing.sm, - borderBottomWidth: 1, - borderBottomColor: c.borderLight, - }, - principalInfo: { flex: 1, minWidth: 0 }, - principalName: { ...typography.body, color: c.text }, - principalEmail: { ...typography.caption, color: c.textMuted }, - roleChips: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: spacing.xs }, - chip: { - paddingHorizontal: spacing.sm, - paddingVertical: 4, - borderRadius: radius.full, - borderWidth: 1, - borderColor: c.border, - backgroundColor: c.surface, - }, - chipActive: { backgroundColor: c.primary, borderColor: c.primary }, - chipText: { ...typography.caption, color: c.text }, - chipTextActive: { color: c.primaryForeground }, - customLabel: { ...typography.caption, color: c.textMuted }, - removeBtn: { marginLeft: 'auto', padding: 4 }, - input: { - minHeight: 40, - borderWidth: 1, - borderColor: c.border, - borderRadius: radius.sm, - paddingHorizontal: spacing.md, - color: c.text, - backgroundColor: c.surface, - ...typography.body, - }, - empty: { ...typography.caption, color: c.textMuted, paddingVertical: spacing.sm }, - candidateRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - paddingVertical: spacing.sm, - borderBottomWidth: 1, - borderBottomColor: c.borderLight, - }, - rowPressed: { backgroundColor: c.surfaceHover }, - doneBtn: { alignSelf: 'flex-end', paddingVertical: spacing.sm, paddingHorizontal: spacing.md }, - doneText: { ...typography.bodyMedium, color: c.primary }, - }); -} diff --git a/src/components/contacts/AddressBookShareSheet.tsx b/src/components/contacts/AddressBookShareSheet.tsx new file mode 100644 index 00000000..e35a9aff --- /dev/null +++ b/src/components/contacts/AddressBookShareSheet.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import type { AddressBook, AddressBookRights } from '../../api/types'; +import { useLocaleStore } from '../../stores/locale-store'; +import { ShareCollectionSheet, type SharePreset } from '../sharing/ShareCollectionSheet'; + +// The webmail's address book presets (lib/jmap/share-rights ADDRESS_BOOK_PRESETS): +// no free/busy, which only calendars have. +export const ADDRESS_BOOK_PRESETS = { + read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false }, + readWrite: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: false }, + manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, +} satisfies Record; + +interface AddressBookShareSheetProps { + /** Null hides the sheet. */ + book: AddressBook | null; + onShare: (bookId: string, principalId: string, rights: AddressBookRights | null) => Promise; + onClose: () => void; +} + +// JMAP sharing for an own address book (webmail ShareCollectionDialog, kind +// "addressBook"): pick a principal, choose a role. +export function AddressBookShareSheet({ book, onShare, onClose }: AddressBookShareSheetProps) { + const t = useLocaleStore((s) => s.t); + const presets = React.useMemo[]>(() => [ + { key: 'read', label: t('sharing.preset.read', 'Read only'), rights: ADDRESS_BOOK_PRESETS.read }, + { key: 'readWrite', label: t('sharing.preset.readWrite', 'Read & write'), rights: ADDRESS_BOOK_PRESETS.readWrite }, + { key: 'manager', label: t('sharing.preset.manager', 'Manager'), rights: ADDRESS_BOOK_PRESETS.manager }, + ], [t]); + return ( + + ); +} diff --git a/src/components/settings/ContactsSettings.tsx b/src/components/settings/ContactsSettings.tsx index 854c6f72..555887ae 100644 --- a/src/components/settings/ContactsSettings.tsx +++ b/src/components/settings/ContactsSettings.tsx @@ -3,12 +3,13 @@ import { Alert, Share, Text, View, StyleSheet, Pressable, TextInput } from 'reac import * as FileSystem from 'expo-file-system/legacy'; import * as Sharing from 'expo-sharing'; import { - Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser, Star, + Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser, Star, Users, } from '../icons'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import Button from '../Button'; import Dialog from '../Dialog'; import { ContactImportSheet, AddressBookPickerSheet } from '../contacts'; +import { AddressBookShareSheet } from '../contacts/AddressBookShareSheet'; import { DeviceSyncSection } from './device-sync/DeviceSyncSection'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; @@ -20,6 +21,7 @@ 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'; +import type { AddressBook } from '../../api/types'; export function ContactsSettings() { const c = useColors(); @@ -45,6 +47,7 @@ export function ContactsSettings() { const deleteAddressBook = useContactsStore((s) => s.deleteAddressBook); const setDefaultAddressBook = useContactsStore((s) => s.setDefaultAddressBook); const getDefaultAddressBookId = useContactsStore((s) => s.getDefaultAddressBookId); + const shareAddressBook = useContactsStore((s) => s.shareAddressBook); const [exporting, setExporting] = React.useState(false); const [importOpen, setImportOpen] = React.useState(false); @@ -57,6 +60,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); + // The book whose sharing sheet is open, as it was when opened. + const [shareTarget, setShareTarget] = React.useState(null); // Android with the native module only (#34). const deviceSync = React.useMemo(() => deviceSyncAvailable(), []); @@ -263,6 +268,21 @@ export function ContactsSettings() { {book.isDefault ? ` · ${t('contacts.address_books.default', 'Default')}` : ''} + {/* Own books the user may share (webmail address-book settings). */} + {!book.isShared && book.myRights?.mayShare ? ( + setShareTarget(book)} + hitSlop={6} + style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel={t('contacts.address_books.share', 'Share address book')} + > + 0 ? c.primary : c.textSecondary} + /> + + ) : null} {!book.isShared && ( { if (!book.isDefault) void handleSetDefault(book.id); }} @@ -392,6 +412,12 @@ export function ContactsSettings() { onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }} /> + setShareTarget(null)} + /> + { + key: string; + label: string; + rights: R; +} + +/** The preset whose rights `rights` match, or 'custom'. */ +export function detectSharePreset(rights: R, presets: SharePreset[]): string { + for (const preset of presets) { + const keys = Object.keys(preset.rights) as (keyof R)[]; + if (keys.every((k) => !!preset.rights[k] === !!rights[k])) return preset.key; + } + return 'custom'; +} + +/** The collection being shared: a calendar, an address book. */ +export interface ShareableCollection { + id: string; + name: string; + shareWith?: Record | null; +} + +interface ShareCollectionSheetProps { + /** Null hides the sheet. */ + collection: ShareableCollection | null; + presets: SharePreset[]; + /** What a new share gets (the webmail: read). */ + addRights: R; + onShare: (collectionId: string, principalId: string, rights: R | null) => Promise; + onClose: () => void; +} + +// JMAP sharing for an owned collection: pick a principal, choose a role +// (webmail ShareCollectionDialog). The principal list comes from the same +// Principal/query the Files share sheet uses. +export function ShareCollectionSheet({ + collection, presets, addRights, onShare, onClose, +}: ShareCollectionSheetProps) { + // 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); + + const [principals, setPrincipals] = React.useState([]); + const [directory, setDirectory] = React.useState>({ status: 'ok' }); + const [loading, setLoading] = React.useState(false); + const [shares, setShares] = React.useState>({}); + const [search, setSearch] = React.useState(''); + const [savingId, setSavingId] = React.useState(null); + + React.useEffect(() => { + if (!collection) return undefined; + let cancelled = false; + setShares(collection.shareWith ?? {}); + setSearch(''); + setLoading(true); + void listPrincipals().then(({ principals: list, ...listing }) => { + if (cancelled) return; + setPrincipals(list); + setDirectory(listing); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [collection]); + + const principalsById = React.useMemo(() => { + const map = new Map(); + for (const p of principals) map.set(p.id, p); + return map; + }, [principals]); + + const sharedEntries = React.useMemo( + () => Object.entries(shares).filter(([, rights]) => rights != null), + [shares], + ); + + const candidates = React.useMemo(() => { + // The sheet is mounted with its 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 + .filter((p) => p.id !== self && shares[p.id] == null) + .filter((p) => + !q || + p.name?.toLowerCase().includes(q) || + p.email?.toLowerCase().includes(q) || + p.description?.toLowerCase().includes(q)) + .slice(0, 25); + }, [principals, shares, search]); + + const applyShare = async (principalId: string, rights: R | null) => { + if (!collection || savingId) return; + setSavingId(principalId); + try { + await onShare(collection.id, principalId, rights); + setShares((prev) => { + const next = { ...prev }; + if (rights == null) delete next[principalId]; + else next[principalId] = rights; + return next; + }); + } catch (e) { + Alert.alert( + t('calendar.share.error', 'Failed to update sharing'), + e instanceof Error ? e.message : String(e), + ); + } finally { + setSavingId(null); + } + }; + + if (!collection) return null; + + // The list below is partial or empty for a reason the user can't see + // otherwise; "Sharing is not available on this server." would be wrong. + const notice = loading ? null : directoryNotice(directory); + const noticeText = + notice === 'failed' ? t('sharing.directory_failed', "Couldn't load the list of users and groups.") + : notice === 'restricted' + ? t( + 'sharing.directory_restricted', + "Your server doesn't allow browsing its user directory, so only your groups and people who have shared with you are listed. An administrator can turn on directory queries in Stalwart's sharing settings.", + ) + : notice === 'not_browsable' + ? t('sharing.directory_not_browsable', "Your server doesn't let you browse its user directory.") + : null; + + const renderPrincipalLabel = (principalId: string) => { + const p = principalsById.get(principalId); + // Stalwart 1.0 with directory queries off hides every principal the user + // has no relationship with, so a sharee may have no name. + const unresolved = !p && !loading; + return ( + + + {p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)} + + {p?.email || unresolved ? ( + {p?.email || principalId} + ) : null} + + ); + }; + + return ( + + + + + + + + + {t('sharing.title', 'Share "{name}"', { name: collection.name })} + + + + + {sharedEntries.length > 0 ? ( + + {t('calendar.share.shared_with', 'Shared with')} + {sharedEntries.map(([principalId, rights]) => { + const preset = detectSharePreset(rights, presets); + const busy = savingId === principalId; + return ( + + {renderPrincipalLabel(principalId)} + + {presets.map((p) => ( + void applyShare(principalId, p.rights)} + disabled={busy} + style={[styles.chip, preset === p.key && styles.chipActive]} + accessibilityRole="button" + accessibilityState={{ selected: preset === p.key, disabled: busy }} + > + + {p.label} + + + ))} + {preset === 'custom' ? ( + {t('sharing.preset.custom', 'Custom')} + ) : null} + void applyShare(principalId, null)} + disabled={busy} + hitSlop={8} + style={styles.removeBtn} + accessibilityRole="button" + accessibilityLabel={t('sharing.remove', 'Remove access')} + > + {busy ? ( + + ) : ( + + )} + + + + ); + })} + + ) : null} + + + {t('calendar.share.add_people', 'Add people')} + + {noticeText ? ( + {noticeText} + ) : null} + {loading ? ( + + ) : candidates.length === 0 ? ( + notice && !search.trim() ? null : ( + + {principals.length === 0 && !notice + ? t('calendar.share.no_principals', 'Sharing is not available on this server.') + : t('calendar.share.no_matches', 'No matches')} + + ) + ) : ( + candidates.map((p) => ( + void applyShare(p.id, addRights)} + disabled={!!savingId} + style={({ pressed }) => [styles.candidateRow, pressed && styles.rowPressed]} + > + {renderPrincipalLabel(p.id)} + {savingId === p.id ? ( + + ) : ( + + )} + + )) + )} + + + + + {t('common.done', 'Done')} + + + + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, + sheet: { + backgroundColor: c.background, + borderTopLeftRadius: radius.xl, + borderTopRightRadius: radius.xl, + padding: spacing.lg, + paddingBottom: spacing.xxl, + maxHeight: '85%', + }, + titleRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.md }, + title: { ...typography.h3, color: c.text, flex: 1 }, + scroll: { flexGrow: 0 }, + section: { marginBottom: spacing.lg, gap: spacing.sm }, + sectionLabel: { ...typography.captionMedium, color: c.textSecondary }, + shareRow: { + gap: spacing.xs, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.borderLight, + }, + principalInfo: { flex: 1, minWidth: 0 }, + principalName: { ...typography.body, color: c.text }, + principalEmail: { ...typography.caption, color: c.textMuted }, + roleChips: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: spacing.xs }, + chip: { + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: radius.full, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + chipActive: { backgroundColor: c.primary, borderColor: c.primary }, + chipText: { ...typography.caption, color: c.text }, + chipTextActive: { color: c.primaryForeground }, + customLabel: { ...typography.caption, color: c.textMuted }, + removeBtn: { marginLeft: 'auto', padding: 4 }, + input: { + minHeight: 40, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + paddingHorizontal: spacing.md, + color: c.text, + backgroundColor: c.surface, + ...typography.body, + }, + empty: { ...typography.caption, color: c.textMuted, paddingVertical: spacing.sm }, + candidateRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.borderLight, + }, + rowPressed: { backgroundColor: c.surfaceHover }, + doneBtn: { alignSelf: 'flex-end', paddingVertical: spacing.sm, paddingHorizontal: spacing.md }, + doneText: { ...typography.bodyMedium, color: c.primary }, + }); +} diff --git a/src/stores/__tests__/contacts-store.test.ts b/src/stores/__tests__/contacts-store.test.ts index b922bd35..2794445c 100644 --- a/src/stores/__tests__/contacts-store.test.ts +++ b/src/stores/__tests__/contacts-store.test.ts @@ -13,6 +13,7 @@ vi.mock('../../api/contacts', () => ({ updateAddressBook: vi.fn(), deleteAddressBook: vi.fn(), setDefaultAddressBook: vi.fn(), + setAddressBookShare: vi.fn(), getContactsInBook: vi.fn(), getContactMedia: vi.fn(), getContactsAccountId: () => 'acc-1', @@ -87,6 +88,7 @@ const mockDeleteContacts = contactsApi.deleteContacts as ReturnType; const mockDeleteAddressBook = contactsApi.deleteAddressBook as ReturnType; const mockSetDefaultAddressBook = contactsApi.setDefaultAddressBook as ReturnType; +const mockSetAddressBookShare = contactsApi.setAddressBookShare as ReturnType; const mockGetContactsInBook = contactsApi.getContactsInBook as ReturnType; const mockQueryRecent = recentApi.queryRecentRecipients as ReturnType; @@ -519,6 +521,23 @@ describe('contacts-store', () => { expect(useContactsStore.getState().addressBooks.map((b) => !!b.isDefault)).toEqual([false, true]); }); + it('shareAddressBook writes to the account the book is in and keeps the shares', async () => { + const read = { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false }; + useContactsStore.setState({ + addressBooks: [ + { id: 'ab-1', name: 'P', shareWith: { bob: read } }, + { id: 'acc-team:ab', originalId: 'ab', accountId: 'acc-team', name: 'Team', isShared: true }, + ], + }); + mockSetAddressBookShare.mockResolvedValue(undefined); + await useContactsStore.getState().shareAddressBook('ab-1', 'carol', read); + expect(mockSetAddressBookShare).toHaveBeenCalledWith('ab-1', 'carol', read, 'acc-1'); + await useContactsStore.getState().shareAddressBook('ab-1', 'bob', null); + expect(useContactsStore.getState().addressBooks[0].shareWith).toEqual({ carol: read }); + await useContactsStore.getState().shareAddressBook('acc-team:ab', 'carol', read); + expect(mockSetAddressBookShare).toHaveBeenLastCalledWith('ab', 'carol', read, 'acc-team'); + }); + it('getDefaultAddressBookId prefers the default own book', () => { useContactsStore.setState({ addressBooks: [ diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts index 5992fcbf..a568451c 100644 --- a/src/stores/contacts-store.ts +++ b/src/stores/contacts-store.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { createPersistStorage, memoizeSlice } from './persist-storage'; -import type { ContactCard, AddressBook, StateChange, EmailAddress } from '../api/types'; +import type { ContactCard, AddressBook, AddressBookRights, StateChange, EmailAddress } from '../api/types'; import { getAddressBooks as fetchPrimaryAddressBooks, getAllAddressBooks as fetchAllAddressBooks, @@ -16,6 +16,7 @@ import { updateAddressBook as apiUpdateAddressBook, deleteAddressBook as apiDeleteAddressBook, setDefaultAddressBook as apiSetDefaultAddressBook, + setAddressBookShare as apiSetAddressBookShare, getContactsInBook, getContactsAccountId, getContactCapableAccountIds, @@ -152,6 +153,8 @@ export interface ContactsState { renameAddressBook: (id: string, name: string) => Promise; deleteAddressBook: (id: string) => Promise; setDefaultAddressBook: (id: string) => Promise; + /** Give a principal rights on an own address book; null takes them away. */ + shareAddressBook: (id: string, principalId: string, rights: AddressBookRights | null) => Promise; /** The book new contacts / imports should land in when none is chosen. */ getDefaultAddressBookId: () => string | null; @@ -924,6 +927,24 @@ export const useContactsStore = create()( }); }, + shareAddressBook: async (id, principalId, rights) => { + const { check } = generationGuard(); + const { originalId, accountId } = bookTarget(id); + // The write goes to the account the book was listed in, even if + // the login changes while it is on its way. + await apiSetAddressBookShare(originalId, principalId, rights, accountId ?? getContactsAccountId()); + check(); + set({ + addressBooks: get().addressBooks.map((b) => { + if (b.id !== id) return b; + const next = { ...(b.shareWith ?? {}) }; + if (rights === null) delete next[principalId]; + else next[principalId] = rights; + return { ...b, shareWith: next }; + }), + }); + }, + getDefaultAddressBookId: () => { const books = get().addressBooks; const own = books.filter((b) => !b.isShared && b.myRights?.mayWrite !== false); From ecf1f3ecd9f6fef4cd80718d5955a2bb80c8bfe9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:38:43 +0100 Subject: [PATCH 08/14] fix: forward out-of-office mail to a unicode domain in its ascii form (bulwarkmail/webmail#1100) --- src/components/settings/VacationSettings.tsx | 10 +++++-- .../__tests__/vacation-forward-idn.test.ts | 29 +++++++++++++++++++ src/lib/sieve/vacation-forward.ts | 10 +++++++ 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 src/lib/sieve/__tests__/vacation-forward-idn.test.ts diff --git a/src/components/settings/VacationSettings.tsx b/src/components/settings/VacationSettings.tsx index 4af40e77..42772a56 100644 --- a/src/components/settings/VacationSettings.tsx +++ b/src/components/settings/VacationSettings.tsx @@ -15,6 +15,8 @@ import { useLocaleStore } from '../../stores/locale-store'; import { useSettingsStore } from '../../stores/settings-store'; import { isValidEmail } from '../../lib/recipients'; import { ownDomains } from '../../lib/sieve/vacation-audience'; +import { vacationForwardAddress } from '../../lib/sieve/vacation-forward'; +import { toUnicodeEmail } from '../../lib/idn'; import type { VacationForward } from '../../lib/sieve/types'; import { htmlToPlainText } from '../../lib/compose-html'; import { stripDangerousTags, escapeHtml } from '../../lib/email-html'; @@ -65,7 +67,8 @@ export function VacationSettings() { // Seed the editor once per load; RichTextEditor only reads initialHtml on mount. const [editorSeed, setEditorSeed] = useState(store.htmlBody || ''); const [forwardEnabled, setForwardEnabled] = useState(store.forward?.enabled ?? false); - const [forwardTo, setForwardTo] = useState(store.forward?.to ?? ''); + // Shown with its domain in Unicode, as it was typed. + const [forwardTo, setForwardTo] = useState(toUnicodeEmail(store.forward?.to ?? '')); const [forwardKeep, setForwardKeep] = useState(store.forward?.keepCopy ?? false); const [audienceOnly, setAudienceOnly] = useState(store.audience?.only ?? 'all'); // "Internal" are the account's own domains, as its identities give them. @@ -92,7 +95,7 @@ export function VacationSettings() { setHtmlBody(store.htmlBody || ''); setEditorSeed(store.htmlBody || ''); setForwardEnabled(store.forward?.enabled ?? false); - setForwardTo(store.forward?.to ?? ''); + setForwardTo(toUnicodeEmail(store.forward?.to ?? '')); setForwardKeep(store.forward?.keepCopy ?? false); setAudienceOnly(store.audience?.only ?? 'all'); }, [ @@ -115,7 +118,8 @@ export function VacationSettings() { // Forwarding as a save stores it: an address that is off is kept for next // time if it is usable, and dropped otherwise. - const forwardAddress = forwardTo.trim(); + // Checked and stored with a Unicode domain in ASCII form (webmail #1100). + const forwardAddress = vacationForwardAddress(forwardTo); const forwardInvalid = store.forwardAvailable && forwardEnabled && !isValidEmail(forwardAddress); const forwardSettings: VacationForwardSettings | null | undefined = !store.forwardAvailable ? undefined diff --git a/src/lib/sieve/__tests__/vacation-forward-idn.test.ts b/src/lib/sieve/__tests__/vacation-forward-idn.test.ts new file mode 100644 index 00000000..68bd407a --- /dev/null +++ b/src/lib/sieve/__tests__/vacation-forward-idn.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { generateScript } from '../generator'; +import { isValidVacationForward, vacationForwardAddress } from '../vacation-forward'; + +// A forward address with a Unicode domain went into the Sieve redirect as +// typed; the server needs the ASCII (xn--) form (bulwarkmail/webmail#1100). + +describe('vacation forwarding to a Unicode domain', () => { + it('writes the redirect with the domain in ASCII form', () => { + const to = vacationForwardAddress(' Ich@bücher.de '); + expect(to).toBe('Ich@xn--bcher-kva.de'); + const forward = { enabled: true, to, keepCopy: false }; + expect(isValidVacationForward(forward)).toBe(true); + const script = generateScript([], undefined, { includeVacation: true, vacationForward: forward }); + expect(script).toContain('redirect "Ich@xn--bcher-kva.de";'); + expect(script).not.toContain('bücher'); + // ASCII addresses stay as they are. + expect(vacationForwardAddress('a@example.org')).toBe('a@example.org'); + }); + + // No RN renderer in the test env: check the settings page's wiring. + it('is what the out-of-office page checks and saves', () => { + const page = fs.readFileSync(path.resolve(__dirname, '../../../components/settings/VacationSettings.tsx'), 'utf8'); + expect(page).toMatch(/const forwardAddress = vacationForwardAddress\(forwardTo\);/); + expect(page).toMatch(/\{ enabled: forwardEnabled, to: forwardAddress, keepCopy: forwardKeep \}/); + }); +}); diff --git a/src/lib/sieve/vacation-forward.ts b/src/lib/sieve/vacation-forward.ts index 1c1d6e8c..bce8bcb6 100644 --- a/src/lib/sieve/vacation-forward.ts +++ b/src/lib/sieve/vacation-forward.ts @@ -1,6 +1,7 @@ import type { VacationForward } from './types'; import { isValidEmail } from '../recipients'; import { isPeriodBoundary } from './period'; +import { toAsciiEmail } from '../idn'; /** * Forwarding from the vacation card (see VacationForward). The generator @@ -15,6 +16,15 @@ export const VACATION_FORWARD_MARKER = '# Vacation forwarding'; */ export const VACATION_FORWARD_MARKER_RE = /^[ \t]*# Vacation forwarding[ \t]*$/m; +/** + * The forwarding address as typed, the way the Sieve redirect needs it: a + * Unicode domain in its ASCII (`xn--`) form, which the server can deliver + * to (webmail #1100). + */ +export function vacationForwardAddress(typed: string): string { + return toAsciiEmail(typed.trim()); +} + /** Whether `value` is forwarding Bulwark can write back as it reads. */ export function isValidVacationForward(value: unknown): value is VacationForward { if (!value || typeof value !== 'object') return false; From 90ce7c1b3c49be74e9ca3b9c29cee5e5e8486200 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:44:28 +0100 Subject: [PATCH 09/14] feat: size the week all-day area from the days in view and fold it to three rows (bulwarkmail/webmail#1122) --- .../__tests__/calendar-i18n.test.ts | 1 + src/components/calendar/AllDayToggle.tsx | 41 +++++++++++ .../calendar/TimeGridScrollView.tsx | 25 +++++-- src/components/calendar/WeekView.tsx | 20 ++++-- .../__tests__/calendar-all-day-area.test.ts | 69 +++++++++++++++++++ src/lib/calendar-time-grid.ts | 60 +++++++++++++++- src/screens/CalendarScreen.tsx | 8 +-- 7 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 src/components/calendar/AllDayToggle.tsx create mode 100644 src/lib/__tests__/calendar-all-day-area.test.ts diff --git a/src/components/__tests__/calendar-i18n.test.ts b/src/components/__tests__/calendar-i18n.test.ts index e513bf86..e54c41ee 100644 --- a/src/components/__tests__/calendar-i18n.test.ts +++ b/src/components/__tests__/calendar-i18n.test.ts @@ -11,6 +11,7 @@ const SRC = join(__dirname, '..', '..'); const TRANSLATED_FILES: Record = { 'screens/CalendarScreen.tsx': [], 'components/calendar/AgendaView.tsx': [], + 'components/calendar/AllDayToggle.tsx': [], 'components/calendar/EventBlock.tsx': [], 'components/calendar/EventCard.tsx': [], 'components/calendar/MonthScrollView.tsx': [], diff --git a/src/components/calendar/AllDayToggle.tsx b/src/components/calendar/AllDayToggle.tsx new file mode 100644 index 00000000..673eb6b0 --- /dev/null +++ b/src/components/calendar/AllDayToggle.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Pressable, StyleSheet, Text } from 'react-native'; +import { ChevronDown, ChevronUp } from '../icons'; +import { typography } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; + +interface Props { + /** Rows the collapsed area leaves out. */ + hiddenRows: number; + expanded: boolean; + onToggle: () => void; +} + +/** + * Under the "All day" label of the week and day grids: "+N" opens the rows + * past the first three, and closes them again (webmail calendar-week-view, + * #1122). + */ +export function AllDayToggle({ hiddenRows, expanded, onToggle }: Props) { + const c = useColors(); + const t = useLocaleStore((s) => s.t); + return ( + + {expanded ? null : +{hiddenRows}} + {expanded ? : } + + ); +} + +const styles = StyleSheet.create({ + button: { flexDirection: 'row', alignItems: 'center', gap: 1, marginTop: 'auto', paddingVertical: 2 }, + count: { ...typography.small }, +}); diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx index 8ded28a2..9ad9210d 100644 --- a/src/components/calendar/TimeGridScrollView.tsx +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -37,7 +37,9 @@ import { displayNow, displayNowMinutes } from '../../lib/calendar-timezone'; import { isInactiveEvent } from '../../lib/calendar-participants'; import { eventBlockColors } from '../../lib/event-colors'; import { + allDayRowsInView, buildAllDaySegments, + shownAllDayRows, columnsPerScreen, headerColumnRange, hourAtOffset, @@ -46,6 +48,7 @@ import { type TimeGridMode, } from '../../lib/calendar-time-grid'; import { AllDayEventBar, TIME_LINE_MIN_MINUTES, TimedEventBlock } from './EventBlock'; +import { AllDayToggle } from './AllDayToggle'; const HOUR_HEIGHT = 48; const GRID_HEIGHT = 24 * HOUR_HEIGHT; @@ -174,16 +177,12 @@ function TimeGridScrollViewInner({ ); // All-day bars over the whole window (cut per week, or per day in the day - // view), so the strip keeps one height while scrolling. + // view). The area is sized from the columns in view, below. const allDaySegments = React.useMemo( () => buildAllDaySegments(index, days, perScreen), [index, days, perScreen], ); - const allDayRows = React.useMemo( - () => allDaySegments.reduce((max, s) => Math.max(max, s.row + 1), 0), - [allDaySegments], - ); - const allDayHeight = allDayRows > 0 ? allDayRows * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs : 0; + const [allDayExpanded, setAllDayExpanded] = React.useState(false); // The first column in view. Kept as a day so columns added at the start // don't move it. @@ -370,8 +369,13 @@ function TimeGridScrollViewInner({ />, ); } + // The all-day area holds the rows of the days in view, three until it is + // expanded (webmail #1122, #1293). + const allDayRows = allDayRowsInView(allDaySegments, anchorIndex, anchorIndex + perScreen - 1); + const allDayShownRows = shownAllDayRows(allDayRows, allDayExpanded); + const allDayHeight = allDayShownRows > 0 ? allDayShownRows * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs : 0; const visibleSegments = allDaySegments.filter( - (s) => s.startIndex <= to && s.startIndex + s.span - 1 >= from, + (s) => s.startIndex <= to && s.startIndex + s.span - 1 >= from && s.row < allDayShownRows, ); const threshold = perScreen === 1 ? 3 : 1; @@ -418,6 +422,13 @@ function TimeGridScrollViewInner({ {t('calendar.events.all_day', 'All day')} + {allDayRows > shownAllDayRows(allDayRows, false) ? ( + setAllDayExpanded((v) => !v)} + /> + ) : null} )} diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index 0d7718c9..b1327fd9 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -21,13 +21,14 @@ import { getEventColor, isTimedEventFullDayOnDate, layoutOverlappingEvents, - packWeekSegments, 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'; +import { AllDayToggle } from './AllDayToggle'; +import { packAllDaySegments, shownAllDayRows } from '../../lib/calendar-time-grid'; const HOUR_HEIGHT = 48; const GUTTER_WIDTH = 44; @@ -91,12 +92,16 @@ function WeekViewInner({ events.filter((e) => !e.showWithoutTime), weekDays, ); - return packWeekSegments([...explicit, ...timedFull]); + return packAllDaySegments([...explicit, ...timedFull]); }, [events, weekDays]); - const allDayRowCount = React.useMemo(() => { + const allDayRows = React.useMemo(() => { return allDaySegments.reduce((max, s) => Math.max(max, s.row + 1), 0); }, [allDaySegments]); + // Three rows until expanded (webmail #1122). + const [allDayExpanded, setAllDayExpanded] = React.useState(false); + const allDayRowCount = shownAllDayRows(allDayRows, allDayExpanded); + const allDayHiddenRows = allDayRows - shownAllDayRows(allDayRows, false); const allDayStripHeight = allDayRowCount > 0 @@ -181,6 +186,13 @@ function WeekViewInner({ {t('calendar.events.all_day', 'All day')} + {allDayHiddenRows > 0 ? ( + setAllDayExpanded((v) => !v)} + /> + ) : null} {/* Empty per-day columns to draw vertical separators */} @@ -189,7 +201,7 @@ function WeekViewInner({ ))} {/* Segments overlay - multi-day events span their date range */} - {allDaySegments.map((segment) => { + {allDaySegments.filter((segment) => segment.row < allDayRowCount).map((segment) => { const leftPct = (segment.startIndex / weekDays.length) * 100; const widthPct = (segment.span / weekDays.length) * 100; return ( diff --git a/src/lib/__tests__/calendar-all-day-area.test.ts b/src/lib/__tests__/calendar-all-day-area.test.ts new file mode 100644 index 00000000..4b0255dc --- /dev/null +++ b/src/lib/__tests__/calendar-all-day-area.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { CalendarEvent } from '../../api/types'; +import type { CalendarWeekSegment } from '../calendar-utils'; +import { + ALL_DAY_COLLAPSED_ROWS, + allDayRowsInView, + packAllDaySegments, + shownAllDayRows, +} from '../calendar-time-grid'; + +// The week grids' all-day area was sized from every week of the scroll +// window, packed task chips in among the events, and grew without limit +// (file 10 "week all-day area (#1122)"). Webmail calendar-week-view. + +const seg = (id: string, startIndex: number, span = 1, title = id): CalendarWeekSegment => ({ + event: { id, title, start: '2026-11-02T00:00:00', showWithoutTime: true } as CalendarEvent, + startIndex, + span, + row: 0, + continuesBefore: false, + continuesAfter: false, +}); + +const rowsOf = (segments: CalendarWeekSegment[]) => + Object.fromEntries(segments.map((s) => [s.event.id, s.row])); + +describe('week all-day area', () => { + it("stacks each day's tasks under that day's events", () => { + // "a task" sorts before "b event" by title; packed together it took row 0. + const packed = packAllDaySegments([ + seg('task:1', 1, 1, 'a task'), + seg('trip', 0, 3, 'b trip'), + seg('event', 1, 1, 'b event'), + seg('task:2', 1, 1, 'c task'), + seg('task:3', 4, 1, 'd task'), + ]); + expect(rowsOf(packed)).toEqual({ trip: 0, event: 1, 'task:1': 2, 'task:2': 3, 'task:3': 0 }); + }); + + it('is sized from the days in view', () => { + const packed = packAllDaySegments([seg('a', 0), seg('b', 0), seg('c', 0), seg('d', 0), seg('e', 8)]); + expect(allDayRowsInView(packed, 0, 6)).toBe(4); + // The next week holds one event: no empty rows from the crowded day. + expect(allDayRowsInView(packed, 7, 13)).toBe(1); + expect(allDayRowsInView(packed, 14, 20)).toBe(0); + }); + + it('shows three rows until expanded', () => { + expect(ALL_DAY_COLLAPSED_ROWS).toBe(3); + expect(shownAllDayRows(5, false)).toBe(3); + expect(shownAllDayRows(5, true)).toBe(5); + expect(shownAllDayRows(2, false)).toBe(2); + }); + + // No RN renderer in the test env: check the grids' wiring. + it('is drawn that way by both week grids', () => { + const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '../../components/calendar', rel), 'utf8'); + const scroll = read('TimeGridScrollView.tsx'); + expect(scroll).toMatch(/allDayRowsInView\(allDaySegments, anchorIndex, anchorIndex \+ perScreen - 1\)/); + expect(scroll).toMatch(/s\.row < allDayShownRows/); + expect(scroll).toMatch(/ segment\.row < allDayRowCount\)/); + expect(week).toMatch(/ s.event.id.startsWith(TASK_CHIP_ID_PREFIX); + const events = packWeekSegments(raw.filter((s) => !isTask(s))); + const tasks = raw.filter(isTask).sort((a, b) => + a.startIndex - b.startIndex || (a.event.title || '').localeCompare(b.event.title || '')); + if (tasks.length === 0) return events; + // Each day's next free row: under every event bar that covers it, then + // under the tasks already put there. + const nextRow = new Map(); + const rowAt = (index: number) => { + let row = nextRow.get(index); + if (row === undefined) { + row = events.reduce( + (max, s) => (index >= s.startIndex && index < s.startIndex + s.span ? Math.max(max, s.row + 1) : max), + 0, + ); + } + return row; + }; + const stacked = tasks.map((task) => { + // A task covers one day; a longer one stacks under every day it covers. + let row = 0; + for (let i = task.startIndex; i < task.startIndex + task.span; i++) row = Math.max(row, rowAt(i)); + for (let i = task.startIndex; i < task.startIndex + task.span; i++) nextRow.set(i, row + 1); + return { ...task, row }; + }); + return [...events, ...stacked]; +} + +/** + * Rows the all-day area needs for the columns `first`..`last` in view: a + * crowded day elsewhere in the window adds no empty rows here (webmail + * #1293). + */ +export function allDayRowsInView(segments: CalendarWeekSegment[], first: number, last: number): number { + let rows = 0; + for (const s of segments) { + if (s.startIndex <= last && s.startIndex + s.span - 1 >= first) rows = Math.max(rows, s.row + 1); + } + return rows; +} + +/** The all-day area shows this many rows until it is expanded (webmail DEFAULT_ALL_DAY_MAX_ROWS). */ +export const ALL_DAY_COLLAPSED_ROWS = 3; + +/** Rows drawn: all of them when expanded or few enough, else the collapsed three. */ +export function shownAllDayRows(rows: number, expanded: boolean): number { + return expanded ? rows : Math.min(rows, ALL_DAY_COLLAPSED_ROWS); } /** The hour a long press at `offsetY` in a day column lands on. */ diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 25c679e6..38eb0432 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -11,7 +11,7 @@ import { useWindowDimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { multiDayCount } from '../lib/calendar-time-grid'; +import { TASK_CHIP_ID_PREFIX, multiDayCount } from '../lib/calendar-time-grid'; import { Calendar as CalendarIcon, ChevronDown, @@ -340,7 +340,7 @@ export default function CalendarScreen() { const allDay = !!task.showWithoutTime || dateOnly; out.push({ ...task, - id: `task:${task.id}`, + id: `${TASK_CHIP_ID_PREFIX}${task.id}`, start: dateOnly ? `${task.due}T00:00:00` : task.due, showWithoutTime: allDay, duration: allDay ? 'P1D' : 'PT30M', @@ -623,8 +623,8 @@ export default function CalendarScreen() { // Task chips route to the tasks sheet; everything else opens the detail sheet. const handleSelectEvent = React.useCallback((event: CalendarEvent) => { - if (event.id.startsWith('task:')) { - setTasksInitialId(event.id.slice('task:'.length)); + if (event.id.startsWith(TASK_CHIP_ID_PREFIX)) { + setTasksInitialId(event.id.slice(TASK_CHIP_ID_PREFIX.length)); setTasksVisible(true); return; } From 01578ac2fbd7f40ff63da2be6829a28121d443cb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:48:55 +0100 Subject: [PATCH 10/14] feat: show calendar events in light tinted colours on request --- src/components/calendar/EventBlock.tsx | 11 +++- src/components/calendar/MonthView.tsx | 13 +++-- .../calendar/TimeGridScrollView.tsx | 7 +++ src/components/calendar/WeekView.tsx | 5 ++ src/components/settings/CalendarSettings.tsx | 14 +++++ .../__tests__/calendar-tinted-events.test.ts | 51 +++++++++++++++++++ src/lib/event-colors.ts | 19 ++++++- src/stores/settings-store.ts | 6 +++ 8 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 src/lib/__tests__/calendar-tinted-events.test.ts diff --git a/src/components/calendar/EventBlock.tsx b/src/components/calendar/EventBlock.tsx index 57cce7c5..b1422085 100644 --- a/src/components/calendar/EventBlock.tsx +++ b/src/components/calendar/EventBlock.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native'; -import type { EventBlockColors } from '../../lib/event-colors'; +import { TINTED_BAR_WIDTH, 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 @@ -80,9 +80,11 @@ export function TimedEventBlock({ styles.block, corners('vertical', continuesBefore, continuesAfter, EVENT_RADIUS), { backgroundColor: colors.fill, borderColor: ringColor }, + colors.bar ? styles.blockWithBar : null, style, ]} > + {colors.bar ? : null} {inactive && ( // The calendar-coloured outline sits inside the ring. + {colors.bar ? : null} {continuesBefore ? '… ' : ''} {title} @@ -164,6 +168,11 @@ const styles = StyleSheet.create({ overflow: 'hidden', }, outline: { ...StyleSheet.absoluteFillObject, borderWidth: 1 }, + // The title clears the light style's bar. + blockWithBar: { paddingStart: 3 + TINTED_BAR_WIDTH }, + barWithBar: { paddingStart: 4 + TINTED_BAR_WIDTH }, + // "Light event colors": the calendar colour along the start edge. + startBar: { position: 'absolute', top: 0, bottom: 0, start: 0, width: TINTED_BAR_WIDTH }, blockTitle: { fontSize: 12, lineHeight: 15, fontWeight: '500' }, blockTime: { fontSize: 10.5, lineHeight: 13, fontWeight: '400', opacity: 0.85 }, struck: { textDecorationLine: 'line-through' }, diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx index f3f99655..3dc2f630 100644 --- a/src/components/calendar/MonthView.tsx +++ b/src/components/calendar/MonthView.tsx @@ -14,6 +14,7 @@ import { import type { Calendar, CalendarEvent } from '../../api/types'; import { componentSizes, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useSettingsStore } from '../../stores/settings-store'; import { buildEventDayIndex, eventsOnDayFromIndex, @@ -26,7 +27,7 @@ import { } from '../../lib/calendar-utils'; import { dayIndexIn, monthChipShowsTime, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll'; import { isInactiveEvent } from '../../lib/calendar-participants'; -import { eventBlockColors } from '../../lib/event-colors'; +import { TINTED_BAR_WIDTH, eventBlockColors } from '../../lib/event-colors'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { dayCellAccessibilityLabel } from '../../lib/calendar-a11y'; import { displayNow } from '../../lib/calendar-timezone'; @@ -156,6 +157,8 @@ function MonthWeekRowInner({ const c = useColors(); const { t } = useCalendarLocale(); const { width } = useWindowDimensions(); + // "Light event colors" (webmail calendarTintedEvents). + const tinted = useSettingsStore((s) => s.calendarTintedEvents); const chipTime = monthChipShowsTime(true, width / 7); if (variant === 'full') { return ( @@ -254,13 +257,14 @@ function MonthWeekRowInner({ // 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); + const colors = eventBlockColors(getEventColor(event, calendars), inactive, c, tinted); return ( @@ -327,6 +331,8 @@ function FullWeekRow({ }) { const { width } = useWindowDimensions(); const { t } = useCalendarLocale(); + // "Light event colors" (webmail calendarTintedEvents). + const tinted = useSettingsStore((s) => s.calendarTintedEvents); const showTime = monthChipShowsTime(showTimeInMonthView, width / 7); return ( @@ -365,7 +371,7 @@ function FullWeekRow({ {dayEvents.slice(0, shown).map((event, idx) => { const inactive = isInactiveEvent(event, currentUserEmails); - const colors = eventBlockColors(getEventColor(event, calendars), inactive, c); + const colors = eventBlockColors(getEventColor(event, calendars), inactive, c, tinted); // No time on events that fill the whole day. const time = showTime && !event.showWithoutTime && !isTimedEventFullDayOnDate(event, d) ? `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ` @@ -376,6 +382,7 @@ function FullWeekRow({ style={[ styles.fullChip, { backgroundColor: colors.fill }, + colors.bar ? { borderStartWidth: TINTED_BAR_WIDTH, borderStartColor: colors.bar } : null, colors.border !== null && [styles.chipInactive, { borderColor: colors.border }], ]} > diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx index 9ad9210d..65f7503e 100644 --- a/src/components/calendar/TimeGridScrollView.tsx +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -19,6 +19,7 @@ import { differenceInCalendarDays, format, type Locale } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useSettingsStore } from '../../stores/settings-store'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { eventAccessibilityLabel } from '../../lib/calendar-a11y'; import { @@ -120,6 +121,8 @@ function TimeGridScrollViewInner({ onCreateAtTime, }: TimeGridScrollViewProps) { const c = useColors(); + // "Light event colors" (webmail calendarTintedEvents). + const tinted = useSettingsStore((s) => s.calendarTintedEvents); const styles = React.useMemo(() => makeStyles(c), [c]); const { locale, t } = useCalendarLocale(); const index = React.useMemo( @@ -448,6 +451,7 @@ function TimeGridScrollViewInner({ getEventColor(segment.event, calendars), isInactiveEvent(segment.event, currentUserEmails), c, + tinted, )} continuesBefore={segment.continuesBefore} continuesAfter={segment.continuesAfter} @@ -606,6 +610,8 @@ const DayColumn = React.memo(function DayColumn({ draftHour: number; }) { const { locale, t } = useCalendarLocale(); + // "Light event colors" (webmail calendarTintedEvents). + const tinted = useSettingsStore((s) => s.calendarTintedEvents); const handleLongPress = React.useCallback( (e: GestureResponderEvent) => { onLongPressAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT)); @@ -639,6 +645,7 @@ const DayColumn = React.memo(function DayColumn({ getEventColor(event, calendars), isInactiveEvent(event, currentUserEmails), colors, + tinted, )} ringColor={colors.background} continuesBefore={continuesBefore} diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index b1327fd9..cfa39e0f 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -11,6 +11,7 @@ import { displayNow, displayNowMinutes, isDisplayToday } from '../../lib/calenda import type { Calendar, CalendarEvent } from '../../api/types'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useSettingsStore } from '../../stores/settings-store'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { eventAccessibilityLabel } from '../../lib/calendar-a11y'; import { @@ -66,6 +67,8 @@ function WeekViewInner({ currentUserEmails, }: WeekViewProps) { const c = useColors(); + // "Light event colors" (webmail calendarTintedEvents). + const tinted = useSettingsStore((s) => s.calendarTintedEvents); const styles = React.useMemo(() => makeStyles(c), [c]); const { locale, t } = useCalendarLocale(); const scrollRef = React.useRef(null); @@ -212,6 +215,7 @@ function WeekViewInner({ getEventColor(segment.event, calendars), isInactiveEvent(segment.event, currentUserEmails), c, + tinted, )} continuesBefore={segment.continuesBefore} continuesAfter={segment.continuesAfter} @@ -287,6 +291,7 @@ function WeekViewInner({ getEventColor(event, calendars), isInactiveEvent(event, currentUserEmails), c, + tinted, )} ringColor={c.background} continuesBefore={continuesBefore} diff --git a/src/components/settings/CalendarSettings.tsx b/src/components/settings/CalendarSettings.tsx index dab6f860..5d575c54 100644 --- a/src/components/settings/CalendarSettings.tsx +++ b/src/components/settings/CalendarSettings.tsx @@ -44,6 +44,7 @@ export function CalendarSettings() { const timeZone = useSettingsStore((s) => s.calendarTimeZone); const showTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth); const showWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers); + const tintedEvents = useSettingsStore((s) => s.calendarTintedEvents); const freeScroll = useSettingsStore((s) => s.calendarFreeScroll); const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar); const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks); @@ -135,6 +136,19 @@ export function CalendarSettings() { /> + + update('calendarTintedEvents', v)} + /> + + fs.readFileSync(path.resolve(__dirname, '../..', rel), 'utf8'); + +describe('light event colours', () => { + it('tints active events and adds the start bar', () => { + expect(eventBlockColors('#3b82f6', false, page, true)).toEqual({ + fill: mixColors('#3b82f6', '#ffffff', 0.2), + text: '#111111', + border: null, + bar: '#3b82f6', + }); + expect(mixColors('#3b82f6', '#ffffff', 0.2)).toBe('#d8e6fd'); + // Solid blocks stay the default. + expect(eventBlockColors('#3b82f6', false, page)).toMatchObject({ fill: '#3b82f6' }); + expect(eventBlockColors('#3b82f6', false, page).bar).toBeUndefined(); + }); + + it('keeps declined and cancelled events outlined', () => { + const colors = eventBlockColors('#3b82f6', true, page, true); + expect(colors.border).toBe('#3b82f6'); + expect(colors.fill).toBe('#ffffff'); + expect(colors.bar).toBeUndefined(); + }); + + it('is a setting, off by default, under the webmail key', () => { + expect(mergeWithDefaults({}).calendarTintedEvents).toBe(false); + expect(mergeWithDefaults({ calendarTintedEvents: true }).calendarTintedEvents).toBe(true); + }); + + // No RN renderer in the test env: check the wiring. + it('is offered in the calendar settings and read by every grid', () => { + expect(read('components/settings/CalendarSettings.tsx')).toMatch(/onChange=\{\(v\) => update\('calendarTintedEvents', v\)\}/); + for (const view of ['MonthView', 'TimeGridScrollView', 'WeekView']) { + const src = read(`components/calendar/${view}.tsx`); + expect(src).toMatch(/const tinted = useSettingsStore\(\(s\) => s\.calendarTintedEvents\);/); + expect(src).toMatch(/eventBlockColors\([\s\S]*?tinted,?\s*\)/); + } + expect(read('components/calendar/EventBlock.tsx').match(/colors\.bar \? Date: Sat, 31 Oct 2026 05:51:52 +0100 Subject: [PATCH 11/14] fix: give the contact, event and files fields a name for screen readers --- .../__tests__/field-names-pim.test.ts | 58 +++++++++++++++++++ src/components/calendar/CalendarEditSheet.tsx | 2 + src/components/calendar/EventModal.tsx | 5 ++ src/components/calendar/ParticipantInput.tsx | 1 + src/components/calendar/TasksSheet.tsx | 2 + .../contacts/AddressBookPickerSheet.tsx | 1 + .../contacts/ContactPickerSheet.tsx | 1 + .../contacts/ContactsSidebarDrawer.tsx | 1 + src/components/contacts/TagAssignSheet.tsx | 1 + src/components/files/ShareSheet.tsx | 1 + src/components/settings/VacationSettings.tsx | 3 + .../sharing/ShareCollectionSheet.tsx | 1 + src/screens/ContactFormScreen.tsx | 31 ++++++++++ src/screens/ContactsScreen.tsx | 1 + src/screens/FilesScreen.tsx | 2 + 15 files changed, 111 insertions(+) create mode 100644 src/components/__tests__/field-names-pim.test.ts diff --git a/src/components/__tests__/field-names-pim.test.ts b/src/components/__tests__/field-names-pim.test.ts new file mode 100644 index 00000000..9469f65a --- /dev/null +++ b/src/components/__tests__/field-names-pim.test.ts @@ -0,0 +1,58 @@ +// Text fields need a name a screen reader can say (file 11 "Form fields +// have no name for screen readers"; field-names.test.ts covers Settings and +// sign-in). The contact form, the event editor and the calendar, contacts +// and files sheets had fields whose only name was their placeholder, or +// their value (the custom reminder's "30"). Scanned so a bare field doesn't +// creep back in. +import { describe, expect, it } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const SRC = join(__dirname, '..', '..'); + +function elementEnd(source: string, from: number): number { + let depth = 0; + for (let i = from; i < source.length; i++) { + const ch = source[i]; + if (ch === '{') depth += 1; + else if (ch === '}') depth -= 1; + else if (depth === 0 && (ch === '>' || (ch === '/' && source[i + 1] === '>'))) return i; + } + return source.length; +} + +/** `file:line` of every TextInput element without an accessibilityLabel. */ +function unnamedFields(file: string): string[] { + const source = readFileSync(join(SRC, file), 'utf8'); + const out: string[] = []; + // `` as a type argument (useRef) is not an element. + for (const m of source.matchAll(/ readdirSync(join(SRC, rel)) + .filter((name) => name.endsWith('.tsx')) + .map((name) => `${rel}/${name}`); + +const FILES = [ + ...dir('components/calendar'), + ...dir('components/contacts'), + ...dir('components/files'), + ...dir('components/sharing'), + 'components/settings/VacationSettings.tsx', + 'screens/CalendarScreen.tsx', + 'screens/ContactFormScreen.tsx', + 'screens/ContactsScreen.tsx', + 'screens/ContactDetailScreen.tsx', + 'screens/FilesScreen.tsx', +]; + +describe('field names in calendar, contacts and files', () => { + it('every field has a name', () => { + expect(FILES.flatMap(unnamedFields)).toEqual([]); + }); +}); diff --git a/src/components/calendar/CalendarEditSheet.tsx b/src/components/calendar/CalendarEditSheet.tsx index b6130c0b..67d9f5c3 100644 --- a/src/components/calendar/CalendarEditSheet.tsx +++ b/src/components/calendar/CalendarEditSheet.tsx @@ -225,6 +225,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend {t('calendar.management.name', 'Name')} {t('calendar.management.description', 'Description')} setCustomAmount(v.replace(/[^0-9]/g, ''))} keyboardType="number-pad" @@ -834,6 +836,7 @@ export function EventModal({
} label={t('calendar.form.location', 'Location')}> } label={t('calendar.form.meeting_link', 'Meeting link')}> setEditor((e) => ({ ...e, title: v }))} placeholder={editor.id ? t('calendar.tasks.title_placeholder', 'Task title') : t('calendar.tasks.add_placeholder', 'Add a task…')} @@ -305,6 +306,7 @@ export function TasksSheet({ {expanded && ( setEditor((e) => ({ ...e, description: v }))} placeholder={t('calendar.tasks.description_placeholder', 'Notes')} diff --git a/src/components/contacts/AddressBookPickerSheet.tsx b/src/components/contacts/AddressBookPickerSheet.tsx index d8958d63..ef6ce5f2 100644 --- a/src/components/contacts/AddressBookPickerSheet.tsx +++ b/src/components/contacts/AddressBookPickerSheet.tsx @@ -128,6 +128,7 @@ export default function AddressBookPickerSheet({ {creating ? ( {t('files.share_add_people', 'Add people')} {t('settings.vacation.message.body_label', 'Message Body')} {t('settings.vacation.message.body_description', 'Plain text message content')} ({ {t('calendar.share.add_people', 'Add people')} updateForm('notes', form.notes.filter((_, idx) => idx !== i))} > )} } label={t('contacts.group', 'Group')}> updateForm('emails', form.emails.filter((_, idx) => idx !== i))} > updateForm('phones', form.phones.filter((_, idx) => idx !== i))} > updateForm('addresses', form.addresses.filter((_, idx) => idx !== i))} > updateForm('personalInfo', form.personalInfo.filter((_, idx) => idx !== i))} > {searchOpen ? ( {props.title} Date: Sat, 31 Oct 2026 05:54:25 +0100 Subject: [PATCH 12/14] test: check address book sharing and the contacts and files account lists on live servers --- .../__tests__/live-stalwart-contacts.test.ts | 85 ++++++++++++++++++- src/api/__tests__/live-stalwart-files.test.ts | 19 +++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/api/__tests__/live-stalwart-contacts.test.ts b/src/api/__tests__/live-stalwart-contacts.test.ts index 1f012675..d7e3567e 100644 --- a/src/api/__tests__/live-stalwart-contacts.test.ts +++ b/src/api/__tests__/live-stalwart-contacts.test.ts @@ -7,7 +7,9 @@ * message. * * Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and - * `BULWARK_LIVE_PASS` are set. + * `BULWARK_LIVE_PASS` are set. `BULWARK_LIVE_USER2` and `BULWARK_LIVE_PASS2` + * name a second account for address book sharing and for which shared + * accounts count as holding contacts (webmail #1239). * * BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \ * BULWARK_LIVE_USER=e2e-tablet-dark@example.org BULWARK_LIVE_PASS=… \ @@ -29,9 +31,13 @@ import { deleteContacts, getAddressBooks, getContact, + getContactCapableAccountIds, + getContactsAccountId, createContact, + setAddressBookShare, updateContact, } from '../contacts'; +import { accountSupports } from '../../lib/account-capability'; import { createSieveScript, deleteSieveScript, @@ -58,6 +64,9 @@ const SERVER = process.env.BULWARK_LIVE_JMAP; const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; const PASS = process.env.BULWARK_LIVE_PASS ?? ''; const live = SERVER && PASS ? describe : describe.skip; +const USER2 = process.env.BULWARK_LIVE_USER2; +const PASS2 = process.env.BULWARK_LIVE_PASS2 ?? PASS; +const liveTwo = SERVER && PASS && USER2 ? describe : describe.skip; const RUN = Date.now().toString(36); const SIEVE_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:sieve']; @@ -338,3 +347,77 @@ live('live Stalwart: contacts and filters (0.16 and 1.0)', () => { }, 60_000); }); }); + +liveTwo('live Stalwart: address book sharing (0.16 and 1.0)', () => { + let other: JMAPClient; + let bookId = ''; + let otherMailbox = ''; + const MAIL_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:principals']; + + const connectMain = async () => { + const mod = await import('../jmap-client'); + singleton = mod.jmapClient as unknown as JMAPClient; + client = new JMAPClient(); + await client.connect(SERVER!, USER, PASS); + singleton.restoreSnapshot(client.snapshot()); + }; + + beforeAll(async () => { + await connectMain(); + other = new JMAPClient(); + await other.connect(SERVER!, USER2!, PASS2); + bookId = (await createAddressBook(`live-share-${RUN}`)).id; + }, 60_000); + + afterAll(async () => { + if (otherMailbox) { + await other.request([['Mailbox/set', { accountId: other.accountId, destroy: [otherMailbox], onDestroyRemoveEmails: true }, '0']], MAIL_USING) + .catch(() => undefined); + } + await connectMain().catch(() => undefined); + if (bookId) await deleteAddressBook(bookId, undefined, { removeContents: true }).catch(() => undefined); + useContactsStore.getState().reset(); + }, 60_000); + + it('shares an address book with another account and takes the share back', async () => { + const principal = other.accountId; + const read = { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false }; + await setAddressBookShare(bookId, principal, read); + const shared = (await getAddressBooks()).find((b) => b.id === bookId); + expect(shared?.shareWith?.[principal]).toMatchObject({ mayRead: true, mayWrite: false }); + + // The other account sees the book in the owner's account. + await other.connect(SERVER!, USER2!, PASS2); + const owner = getContactsAccountId(); + const [[, got]] = (await other.request([['AddressBook/get', { accountId: owner, ids: null }, '0']], + ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:contacts'])).methodResponses as [string, { list?: { id: string }[] }, string][]; + expect((got.list ?? []).map((b) => b.id)).toContain(bookId); + + // Read & write, then nothing. + await setAddressBookShare(bookId, principal, { ...read, mayWrite: true }); + expect((await getAddressBooks()).find((b) => b.id === bookId)?.shareWith?.[principal]).toMatchObject({ mayWrite: true }); + await setAddressBookShare(bookId, principal, null); + expect((await getAddressBooks()).find((b) => b.id === bookId)?.shareWith?.[principal]).toBeUndefined(); + }, 60_000); + + it('lists a shared account for contacts only as far as its capabilities go', async () => { + // A mailbox shared by the other account puts that account in the session. + const [[, created]] = (await other.request([['Mailbox/set', { + accountId: other.accountId, + create: { m: { name: `${RUN} shared`, shareWith: { [client.accountId]: { mayReadItems: true } } } }, + }, '0']], MAIL_USING)).methodResponses as [string, { created?: Record; notCreated?: unknown }, string][]; + otherMailbox = created.created?.m?.id ?? ''; + expect(otherMailbox, JSON.stringify(created.notCreated)).toBeTruthy(); + await connectMain(); + const session = client.currentSession!; + const sharer = session.accounts?.[other.accountId]; + expect(sharer, "the sharer's account is in the session").toBeTruthy(); + const ids = getContactCapableAccountIds(); + expect(ids[0]).toBe(getContactsAccountId()); + // Whatever the server lists for the sharer decides, not that it is shared. + expect(ids.includes(other.accountId)).toBe(accountSupports(sharer, session.capabilities, 'urn:ietf:params:jmap:contacts')); + for (const id of ids.slice(1)) { + expect(accountSupports(session.accounts?.[id], session.capabilities, 'urn:ietf:params:jmap:contacts')).toBe(true); + } + }, 60_000); +}); diff --git a/src/api/__tests__/live-stalwart-files.test.ts b/src/api/__tests__/live-stalwart-files.test.ts index 3a9a2c41..0bef1a13 100644 --- a/src/api/__tests__/live-stalwart-files.test.ts +++ b/src/api/__tests__/live-stalwart-files.test.ts @@ -476,3 +476,22 @@ live('live Stalwart files trash', () => { expect(after.some((n) => n.id === file.id)).toBe(false); }, 90_000); }); + +live('live Stalwart files: which accounts hold files (webmail #1239)', () => { + beforeAll(async () => { + await connectSingleton(); + }, 60_000); + + it('lists an account for files only when its capabilities allow it', async () => { + const { filesCapableAccountIds, accountSupportsFiles } = await import('../files'); + const session = client.currentSession!; + const ids = filesCapableAccountIds(); + expect(ids[0]).toBe(filesAccountId()); + for (const [id, account] of Object.entries(session.accounts ?? {})) { + if (id === ids[0]) continue; + expect(ids.includes(id), id).toBe(accountSupportsFiles(account, session.capabilities)); + } + // The listing reads exactly those accounts. + await expect(getAllFileNodesAcrossAccounts()).resolves.toBeInstanceOf(Array); + }, 60_000); +}); From 6452b47ca2bb3c7c82f8a961e23baf488c97fd5b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:56:22 +0100 Subject: [PATCH 13/14] test: follow the event editor's save into the offer to save without invitations --- src/screens/__tests__/calendar-account-change-silent.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/__tests__/calendar-account-change-silent.test.ts b/src/screens/__tests__/calendar-account-change-silent.test.ts index c1280b62..5a324034 100644 --- a/src/screens/__tests__/calendar-account-change-silent.test.ts +++ b/src/screens/__tests__/calendar-account-change-silent.test.ts @@ -31,7 +31,7 @@ describe('a calendar write the account switch stopped shows no error', () => { }); it('EventModal save', () => { - expect(beforeAlert('components/calendar/EventModal.tsx', 'await onSave(data, calendarId, {')).toMatch(SILENT); + expect(beforeAlert('components/calendar/EventModal.tsx', '=> onSave(data, calendarId, {')).toMatch(SILENT); }); it('CalendarScreen gives the scope edits one account for the whole operation', () => { From 62867f3f3dcc1c1d0c0d3e695dc4fbc3d0afef51 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:57:18 +0100 Subject: [PATCH 14/14] docs: tick the rows the second calendar pass fixed --- docs/parity/05-calendar.md | 4 ++-- docs/parity/06-contacts.md | 4 ++-- docs/parity/07-filters-vacation-files.md | 2 +- docs/parity/10-webmail-1.10-to-1.18.md | 10 +++++----- docs/parity/11-webmail-1.19.md | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/parity/05-calendar.md b/docs/parity/05-calendar.md index daa02e32..a4c40b68 100644 --- a/docs/parity/05-calendar.md +++ b/docs/parity/05-calendar.md @@ -214,7 +214,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi - What RN does: `formatRange` uses `eventTimeRange` -> `getEventEndDate` (exclusive), so a single-day all-day event on Mar 1 prints "Mar 1 – Mar 2" and a two-day one "Mar 1 – Mar 3" (`src/components/calendar/EventDetailSheet.tsx:63-74`, `src/lib/calendar-utils.ts:84-90`). - Fix hint: for `allDay` use `getEventDisplayEndDate(event)` before comparing/formatting. -- [ ] **Deep links to calendar/event** — `P3` — `missing` — partly: incoming links are parsed since 5802cae (`src/navigation/linking.ts`), and an event link opens the event since 9282ee6; a date link only opens the Calendar tab — not done: date deep links need a route in App.tsx/linking.ts (same verdict as the 10 row) +- [x] **Deep links to calendar/event** — `P3` — `missing` — incoming links are parsed since 5802cae (`src/navigation/linking.ts`), an event link opens the event since 9282ee6, a date link opens its view and day since fe455370 (same verdict as the 10 row) - What WEB does: `parseCalendarPath`/`buildCalendarPath` handle `/calendar//?event=` (ref `lib/deep-links.ts:272-300`, changelog 1.8.3 "Deep links for mail, calendar, contacts, files"). - What RN does: `Calendar: undefined` route params (`src/navigation/types.ts:33`); `handleDeepLink` hands an event to the Calendar tab through `pending-calendar-open`, the same path a tapped reminder takes, but ignores the date. - Fix hint: accept `{ date?, eventId? }` params on the Calendar route and open the detail sheet. @@ -246,7 +246,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi - What RN does: `createEvent` merges the `/set` echo over the payload (`src/api/calendar.ts:272-274`) and appends without expansion (`src/stores/calendar-store.ts:286-304`). - Fix hint: after create, `CalendarEvent/get` the id (RN `getEvents([id])`) and, if `recurrenceRules` is set, run `expandRecurringEvents` for `loadedRange` or call `refresh()`. -- [ ] **Push: FCM subscription excludes `CalendarEvent`/`Calendar` types** — `P3` — `partial` — deferred: local reminders (8e1d03c) are resynced at launch, on `Calendar`/`CalendarEvent` state changes and on return to the foreground (4b903ed), but nothing refreshes them in the background; adding the calendar types to the push subscription lives in `src/lib/push-notifications.ts` (push area) and would need a background refresh + reschedule hook +- [ ] **Push: FCM subscription excludes `CalendarEvent`/`Calendar` types** — `P3` — `partial` — deferred: local reminders (8e1d03c) are resynced at launch, on `Calendar`/`CalendarEvent` state changes and on return to the foreground (4b903ed), but nothing refreshes them in the background; left in the second calendar pass: background reminder refresh without device sync: left, it needs native work: the push subscription would have to carry `CalendarEvent`/`Calendar` for every account (src/lib/push-notifications.ts), PushRouting.kt would have to start a headless JS task for a calendar-only push (today it starts nothing without device sync), and that task would need a reminder scheduler that runs without the restored session and stores the in-app one (src/lib/calendar-notifications.ts) depends on; Android only, and not testable without a device. While the app runs, reminders are rescheduled on every calendar change already (10 row) - What WEB does: push subscription covers calendar types so the sidebar refreshes on external changes (FEATURES.md "JMAP push keeps everything in sync"). - What RN does: foreground EventSource/polling dispatches to `useCalendarStore.handleStateChange` (`App.tsx:390-397`, `src/stores/calendar-store.ts:261-284`) — at parity while the app is open; the background push subscription carries only `['EmailDelivery']` since 00faceb (`src/lib/push-notifications.ts`), so calendar changes never wake the app. - Fix hint: leave as is unless background calendar refresh becomes needed. diff --git a/docs/parity/06-contacts.md b/docs/parity/06-contacts.md index 6e3409b0..c25f2763 100644 --- a/docs/parity/06-contacts.md +++ b/docs/parity/06-contacts.md @@ -73,7 +73,7 @@ RN covers the visible surface reasonably well (list with alphabetical index, det - What RN does: shows the trash icon whenever `books.length > 1 && mayDelete !== false` (`src/components/settings/ContactsSettings.tsx:209`); the server then rejects and the user sees a raw error. - Fix hint: add `!book.isDefault` to the guard. -- [ ] **Address book share / visibility (subscription) management missing** — `P3` — `missing` — not done: needs its own share sheet; ran out of time (same verdict as the 10 row) +- [x] **Address book share / visibility (subscription) management missing** — `P3` — `missing` — sharing fixed in 96b163a1 (same verdict as the 10 row); visibility (subscription): the webmail has no subscription switch for address books to port - deferred: `shareWith`/`myRights` are now requested explicitly (580e010) and typed on `AddressBook`; no sharing sheet on mobile yet (reuse the calendar-sharing sheet when it exists). - What WEB does: `shareAddressBook` via `shareWith/` patch + ShareCollectionDialog (`stores/contact-store.ts:1064-1085`, `components/contacts/contacts-app.tsx:1142-1158`); sidebar shows a shared indicator (`contacts-sidebar.tsx:879-881`). - What RN does: nothing; `getAddressBooks` requests no `properties` so `shareWith` is whatever the server defaults to (`src/api/contacts.ts:17-24`; WEB requests `ADDRESS_BOOK_PROPERTIES` incl. `shareWith`, `lib/jmap/client.ts:252-261`, #257). @@ -209,7 +209,7 @@ RN covers the visible surface reasonably well (list with alphabetical index, det - What RN does: none (see previous item). - Fix hint: same sheet as above; show the matching card's phone/org and an "Open" that navigates to `ContactDetail`. -- [ ] **Contact deep links (`/contacts/[/edit]`, `/contacts/new`)** — `P3` — `missing` — not done: needs routes in linking.ts, the platform agent's file (same verdict as the 10 row) +- [x] **Contact deep links (`/contacts/[/edit]`, `/contacts/new`)** — `P3` — `missing` — fixed in ecefd1cb (same verdict as the 10 row) - partly: `/contacts/` opens the contact since 5802cae; `/contacts/new` and `/contacts//edit` only open the Contacts tab. - What WEB does: `parseContactsPath`/`buildContactsPath` (`lib/deep-links.ts:323-329`), applied once on mount (`components/contacts/contacts-app.tsx:182-224`). - What RN does: `src/navigation/linking.ts` (5802cae) maps `/contacts/` to `ContactDetail { contactId }` and every other contacts path to the tab; it never opens `ContactForm`. diff --git a/docs/parity/07-filters-vacation-files.md b/docs/parity/07-filters-vacation-files.md index 82808302..6ed07b8d 100644 --- a/docs/parity/07-filters-vacation-files.md +++ b/docs/parity/07-filters-vacation-files.md @@ -143,7 +143,7 @@ Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests a - What WEB does: on first listing, reparents nodes named with `/`, `∕`, `⁄`, `/` separators and replaces blob-backed "dir marker" files with real folders, with a progress overlay (`files-app.tsx:245-263`, `710-734`). - What RN does: reads only the real hierarchy (`src/api/files.ts:6-14` comment acknowledges WEB migrates). A user who never opens WEB sees legacy files as flat names at the root. Acceptable if WEB is always used at least once; otherwise port `migrateLegacyFlatNodes`. -- [ ] **Deep link `/files/` and `?preview=` not handled** — `P3` — `missing` (changelog 1.8.1 "Deep links for mail, calendar, contacts, files") — partly: `/files` links open the Files tab since 5802cae; folder paths and `?preview=` are ignored — not done: ran out of time (same verdict as the 10 row) +- [x] **Deep link `/files/` and `?preview=` not handled** — `P3` — `missing` (changelog 1.8.1 "Deep links for mail, calendar, contacts, files") — `/files` links open the Files tab since 5802cae; folder paths, `?preview=` and `?shared=` since d684b00e (same verdict as the 10 row) - What WEB does: `parseFilesPath`/`buildFilesPath` walk the drive to the folder and open the preview (`components/files/files-app.tsx:190-278`; `lib/deep-links.ts:381`). - What RN does: `src/navigation/linking.ts` (5802cae) turns any `/files/...` link into `{ kind: 'files' }` and opens the tab; `Files: undefined` in `src/navigation/types.ts:35`. - Fix hint: parse the rest of the path in `linking.ts`, pass it to `Files` as a `path` param and walk `allNodes` by names. diff --git a/docs/parity/10-webmail-1.10-to-1.18.md b/docs/parity/10-webmail-1.10-to-1.18.md index 024775c3..63151408 100644 --- a/docs/parity/10-webmail-1.10-to-1.18.md +++ b/docs/parity/10-webmail-1.10-to-1.18.md @@ -70,7 +70,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - What RN does: clearAllFeatureStores (`src/stores/auth-store.ts:126-134`) never clears `src/stores/search-history-store.ts:44`. - Fix hint: clear it, and check the other persisted stores in the same pass. -- [x] **Internationalized domains** — `P3` — `missing` — fixed in f5875f87 (sign-in, discovery, account rows; the composer's recipient check goes with the compose branch) +- [x] **Internationalized domains** — `P3` — `missing` — fixed in f5875f87 (sign-in, discovery, account rows; the composer's recipient check goes with the compose branch), ecf1f3ec (the auto-reply forward address goes into the Sieve redirect in ASCII form) - What WEB does: `lib/idn.ts:25` (#1100). What RN does: ASCII-only checks (`src/lib/recipients.ts:31`, `src/lib/server-discovery.ts:78`). - Fix hint: toASCII on the wire, Unicode on screen. @@ -323,7 +323,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - [x] **Moving a task into or within a shared calendar (#1204)** — `P3` — `partial` — fixed in b6995bb6 - What WEB does: `lib/jmap/client.ts:9473`. What RN does: updateTask sends unmapped calendarIds (`src/stores/calendar-store.ts:1311-1315`). -- [ ] **Working hours (#1164); week all-day area (#1122); light tinted colours; Jalali; birthday colour; copy title/append note; component set on new calendars (#760); date deep links; background reminder refresh without device sync** — `P3` — `missing`/`partial` — birthday colour fixed in 574db426, copy title/append note in baa3ff59 (duplicate and .ics export were there); not done: week all-day area, light tinted colours (needs a toggle on the platform agent's calendar settings page), date deep links (route in App.tsx/linking.ts, see the calendar report) and background reminder refresh: ran out of time; working hours and Jalali: own feature, not in this run; component set (#760): needs a CalDAV client (05 row) +- [x] **Working hours (#1164); week all-day area (#1122); light tinted colours; Jalali; birthday colour; copy title/append note; component set on new calendars (#760); date deep links; background reminder refresh without device sync** — `P3` — `missing`/`partial` — birthday colour fixed in 574db426, copy title/append note in baa3ff59 (duplicate and .ics export were there), week all-day area in 90ce7c1b (sized from the days in view, tasks under their day's events, three rows with +N), light tinted colours in 01578ac2 (Settings > Calendar "Light event colors"), date deep links in fe455370 (`/calendar//`, a bare date keeps the view, `tasks` opens the task list); left: background reminder refresh without device sync: left, it needs native work: the push subscription would have to carry `CalendarEvent`/`Calendar` for every account (src/lib/push-notifications.ts), PushRouting.kt would have to start a headless JS task for a calendar-only push (today it starts nothing without device sync), and that task would need a reminder scheduler that runs without the restored session and stores the in-app one (src/lib/calendar-notifications.ts) depends on; Android only, and not testable without a device. While the app runs, reminders are rescheduled on every calendar change already; working hours and Jalali: own feature, not in this run; component set (#760): needs a CalDAV client (05 row) - What RN does: `src/components/calendar/WeekView.tsx`, `src/components/calendar/TimeGridScrollView.tsx:177-185`, `src/lib/birthday-calendar.ts:6`, `src/components/calendar/EventDetailSheet.tsx:422`, `src/api/calendar.ts:1150`, `src/navigation/linking.ts:151`, `:288-299`, `src/lib/push-notifications.ts:243`. - [ ] **Free/busy, CalendarEventNotification, organizer from ParticipantIdentity (1.10.0)** — `P3` — `missing` — not done: own feature, not in this run @@ -350,7 +350,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - [x] **vCard 4.0 export (#1407)** — `P3` — `bug` — fixed in 07fd03e2 - What RN does: dashes in dates, MOBILE instead of CELL (`src/lib/vcard.ts:21`, `:24`, `:1264`). -- [ ] **Delete contact categories; address book sharing; list filters; deep links new/edit** — `P3` — `missing`/`partial` — category delete with Undo fixed in 65ffef71, list filters in 18c6b03f; not done: address book sharing (needs its own share sheet, CalendarShareSheet is calendar-only; ran out of time) and deep links new/edit (route in linking.ts, platform agent's file) +- [x] **Delete contact categories; address book sharing; list filters; deep links new/edit** — `P3` — `missing`/`partial` — category delete with Undo fixed in 65ffef71, list filters in 18c6b03f, address book sharing in 96b163a1 (Settings > Contacts, own books the user may share; one share sheet with the calendars), deep links new/edit in ecefd1cb (`/contacts/new?email=&name=`, `/contacts//edit`, the older `?contactId=`/`?addEmail=` form) - What WEB does: `components/settings/address-book-management-settings.tsx:84`, `:435`, `components/contacts/contact-list.tsx:30`, `lib/deep-links.ts:394`. What RN does: `src/components/contacts/ContactsSidebarDrawer.tsx:144`, `src/components/settings/ContactsSettings.tsx:33`, `src/screens/ContactsScreen.tsx:163`, `src/navigation/linking.ts:155`. ### Files @@ -362,7 +362,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - [x] **Save an attachment to Files (incl. shared and group mail)** — `P2` — `missing` — fixed in 8e07b7d8, 39e82f99 - What WEB does: `components/email/save-to-files-dialog.tsx:43`, `lib/jmap/client.ts:9981`. What RN does: `src/components/email/AttachmentChips.tsx:183`; `src/api/files.ts:411` has createFileNodeFromBlob. -- [ ] **Copy folders, numbered new folder names, next/previous in preview, starred and recent, deep links** — `P3` — `partial`/`missing` — copy folders fixed in a0f586f9; numbered new folder names: no webmail behaviour to port (its New folder dialog also takes the typed name, file-browser.tsx:1389); not done: next/previous in preview (needs header buttons in AttachmentPreviewModal, the reader agent's file), starred and recent and deep links: ran out of time +- [x] **Copy folders, numbered new folder names, next/previous in preview, starred and recent, deep links** — `P3` — `partial`/`missing` — copy folders fixed in a0f586f9; numbered new folder names: no webmail behaviour to port (its New folder dialog also takes the typed name, file-browser.tsx:1389); next/previous in preview in e3fc67e7; starred and recent in cad9296a (kept on the device per account like the webmail's localStorage, listed above the root's files, Star in a folder's actions); deep links in d684b00e (`/files/`, `?preview=`, `?shared=`) - What WEB does: `lib/jmap/client.ts:10155`, `stores/file-store.ts:640`, `lib/deep-links.ts:444`. What RN does: `src/api/files.ts:511-519`, `:328-345`, `src/components/files/FilePreviewModal.tsx`, `src/navigation/linking.ts:158`, `:307`. - [ ] **Sharing fails on Stalwart before 0.16.6** — `P3` — `bug` @@ -419,7 +419,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - What WEB does: Mailbox/AddressBook/Calendar shareWith in one batch (`lib/delegation/delegation.ts:96`). - What RN does: no mailbox sharing (`src/components/settings/FolderSettings.tsx`). -- [x] **Shared accounts treated as capable when their capabilities say otherwise (#1239)** — `P3` — `bug` — fixed in 67732348 (filters and auto-reply; contacts and files go with the calendar branch) +- [x] **Shared accounts treated as capable when their capabilities say otherwise (#1239)** — `P3` — `bug` — fixed in 67732348 (filters and auto-reply), 7a15c0c6 (contacts and files) - What WEB does: `lib/jmap/client.ts:6431`. What RN does: `src/api/contacts.ts:76`, `src/api/files.ts:98`, `src/api/sieve.ts:43`, `src/api/vacation.ts:77`. - [x] **Storage quota ignores softLimit (#1242)** — `P3` — `bug` — fixed in af86339b diff --git a/docs/parity/11-webmail-1.19.md b/docs/parity/11-webmail-1.19.md index eb86bff8..2e37f5ec 100644 --- a/docs/parity/11-webmail-1.19.md +++ b/docs/parity/11-webmail-1.19.md @@ -135,7 +135,7 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`; - What RN does: no page (`src/screens/SettingsScreen.tsx:150-174`); text size 0.92/1/1.12 (`src/theme/dynamic.ts:14-18`). Text follows the system font scale; reduce motion follows the system (`src/theme/dynamic.ts:88-120`). - Fix hint: a page that groups the existing switches, widens the text size range and takes the presets that make sense on a phone. -- [x] **Form fields have no name for screen readers** — `P2` — `bug` — fixed in ff5a8c91 (settings and sign-in; contact form and event editor are listed for their branches) +- [x] **Form fields have no name for screen readers** — `P2` — `bug` — fixed in ff5a8c91 (settings and sign-in), 7ba00be6 (contact form, event editor, calendar, contacts and files sheets, auto-reply) - What WEB does: every field has a label tied to it (`components/ui/field.tsx:42`; ef13556a0, 0e2a05e0e, dcc21acd0, 4e2affe40, f73643bbb). - What RN does: the shared Input draws its label as separate Text (`src/components/Input.tsx:40-55`); the Security password and code fields have no label at all (`src/components/settings/AccountSecuritySettings.tsx:181`, `:191`, `:198`); contact fields rely on placeholders (`src/screens/ContactFormScreen.tsx:823-834`); the reminder field's only name is "30" (`src/components/calendar/EventModal.tsx:772`, `:817`). - Fix hint: Input passes its label as accessibilityLabel; every bare TextInput gets one.