mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
Merge branch 'cu-calendar2' into catch-up-2026-11
# Conflicts: # docs/parity/10-webmail-1.10-to-1.18.md # docs/parity/11-webmail-1.19.md
This commit is contained in:
@@ -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/<view>/<date>?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.
|
||||
|
||||
@@ -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/<principal>` 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/<id>[/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/<id>[/edit]`, `/contacts/new`)** — `P3` — `missing` — fixed in ecefd1cb (same verdict as the 10 row)
|
||||
- partly: `/contacts/<id>` opens the contact since 5802cae; `/contacts/new` and `/contacts/<id>/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/<id>` to `ContactDetail { contactId }` and every other contacts path to the tab; it never opens `ContactForm`.
|
||||
|
||||
@@ -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/<folder>` 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/<folder>` 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.
|
||||
|
||||
@@ -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) and 4932764a (the composer takes recipients on Unicode domains and sends them in ASCII form; the From identity and a sender on one of your own domains show as written in the composer and the reader; unit tests only, the test servers have no IDN domain); left: the sender in the mail list rows (EmailListScreen, UnifiedInboxScreen) still shows the xn-- form on your own domains — list rows in b35d15ca
|
||||
- [x] **Internationalized domains** — `P3` — `missing` — fixed in f5875f87 (sign-in, discovery, account rows), 4932764a (composer recipients sent in ASCII form; own-domain senders shown as written in the composer and reader), b35d15ca (mail list rows), ecf1f3ec (auto-reply forward address in ASCII form); unit tests only, the test servers have no IDN domain
|
||||
- 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/<view>/<date>`, 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/<id>/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/<path>`, `?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
|
||||
|
||||
@@ -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), 98b962a8 (composer, quick reply, folder name prompt, template search), 3c3274e4 (mail search, filter panel, unified search); calendar, contacts, Files and the auto-reply editor in the second calendar pass
|
||||
- [x] **Form fields have no name for screen readers** — `P2` — `bug` — fixed in ff5a8c91 (settings and sign-in), 98b962a8 (composer, quick reply, folder name prompt, template search), 3c3274e4 (mail search, filter panel, unified search), 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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const session = {
|
||||
capabilities: { 'urn:ietf:params:jmap:contacts': {} } as Record<string, unknown>,
|
||||
primaryAccounts: {} as Record<string, string>,
|
||||
accounts: {} as Record<string, { name: string; isPersonal: boolean; accountCapabilities?: Record<string, unknown> }>,
|
||||
};
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
updateAddressBook,
|
||||
setDefaultAddressBook,
|
||||
deleteAddressBook,
|
||||
setAddressBookShare,
|
||||
} from '../contacts';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
@@ -60,6 +62,39 @@ 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('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', () => {
|
||||
|
||||
@@ -60,8 +60,12 @@ const mockMaxObjectsInGet = jmapClient.getMaxObjectsInGet as ReturnType<typeof v
|
||||
const mockMaxCallsInRequest = jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>;
|
||||
const mockMaxObjectsInSet = jmapClient.getMaxObjectsInSet as ReturnType<typeof vi.fn>;
|
||||
|
||||
// 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', () => {
|
||||
|
||||
@@ -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,
|
||||
@@ -60,6 +66,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'];
|
||||
@@ -368,3 +377,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<string, { id: string }>; 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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+37
-7
@@ -1,9 +1,10 @@
|
||||
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';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -377,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<void> {
|
||||
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<string, unknown> | null; notUpdated?: Record<string, SetError> }>(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`
|
||||
|
||||
+11
-13
@@ -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<string, unknown> | 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<FileNode[]> {
|
||||
}
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
@@ -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*<Pressable\s*onPress=\{\(\) => setShareTarget\(book\)\}/);
|
||||
expect(settings).toMatch(/<AddressBookShareSheet\s*book=\{shareTarget\}\s*onShare=\{shareAddressBook\}/);
|
||||
});
|
||||
|
||||
it('shares one sheet with the calendars', () => {
|
||||
expect(read('components/calendar/CalendarShareSheet.tsx')).toMatch(/<ShareCollectionSheet/);
|
||||
expect(read('components/contacts/AddressBookShareSheet.tsx')).toMatch(/<ShareCollectionSheet/);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ const SRC = join(__dirname, '..', '..');
|
||||
const TRANSLATED_FILES: Record<string, string[]> = {
|
||||
'screens/CalendarScreen.tsx': [],
|
||||
'components/calendar/AgendaView.tsx': [],
|
||||
'components/calendar/AllDayToggle.tsx': [],
|
||||
'components/calendar/EventBlock.tsx': [],
|
||||
'components/calendar/EventCard.tsx': [],
|
||||
'components/calendar/MonthScrollView.tsx': [],
|
||||
@@ -19,6 +20,8 @@ const TRANSLATED_FILES: Record<string, string[]> = {
|
||||
'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.
|
||||
|
||||
@@ -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[] = [];
|
||||
// `<TextInput>` as a type argument (useRef<TextInput>) is not an element.
|
||||
for (const m of source.matchAll(/<TextInput(?=\s)/g)) {
|
||||
const element = source.slice(m.index!, elementEnd(source, m.index! + m[0].length));
|
||||
if (/\baccessibilityLabel=/.test(element)) continue;
|
||||
out.push(`${file}:${source.slice(0, m.index!).split('\n').length}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const dir = (rel: string) => 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
onPress={onToggle}
|
||||
hitSlop={8}
|
||||
style={styles.button}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={expanded ? t('calendar.events.show_less', 'Show less') : t('calendar.events.show_more', 'Show more')}
|
||||
accessibilityState={{ expanded }}
|
||||
>
|
||||
{expanded ? null : <Text style={[styles.count, { color: c.textMuted }]}>+{hiddenRows}</Text>}
|
||||
{expanded ? <ChevronUp size={12} color={c.textMuted} /> : <ChevronDown size={12} color={c.textMuted} />}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: { flexDirection: 'row', alignItems: 'center', gap: 1, marginTop: 'auto', paddingVertical: 2 },
|
||||
count: { ...typography.small },
|
||||
});
|
||||
@@ -225,6 +225,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
||||
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={styles.body}>
|
||||
<Text style={styles.label}>{t('calendar.management.name', 'Name')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabel={t('calendar.management.name', 'Name')}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder={t('calendar.management.name_placeholder', 'Calendar name')}
|
||||
@@ -308,6 +309,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
||||
)}
|
||||
<Text style={styles.label}>{t('calendar.management.description', 'Description')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabel={t('calendar.management.description', 'Description')}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder={t('calendar.management.description_placeholder', 'Optional')}
|
||||
|
||||
@@ -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<RolePreset, CalendarRights> = {
|
||||
export const CALENDAR_PRESETS: Record<RolePreset, CalendarRights> = {
|
||||
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<RolePreset, CalendarRights> = {
|
||||
},
|
||||
};
|
||||
|
||||
// The webmail's sharing presets (same rights, same names).
|
||||
const PRESET_LABEL_KEYS: Record<RolePreset, [string, string]> = {
|
||||
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<void>;
|
||||
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<Principal[]>([]);
|
||||
const [directory, setDirectory] = React.useState<Omit<PrincipalListing, 'principals'>>({ status: 'ok' });
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [shares, setShares] = React.useState<Record<string, CalendarRights>>({});
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [savingId, setSavingId] = React.useState<string | null>(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<string, Principal>();
|
||||
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 (
|
||||
<View style={styles.principalInfo}>
|
||||
<Text style={styles.principalName} numberOfLines={1}>
|
||||
{p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)}
|
||||
</Text>
|
||||
{p?.email || unresolved ? (
|
||||
<Text style={styles.principalEmail} numberOfLines={1}>{p?.email || principalId}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// The webmail's sharing presets (same rights, same names).
|
||||
const presets = React.useMemo<SharePreset<CalendarRights>[]>(() => [
|
||||
{ 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 (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.titleRow}>
|
||||
<Users size={18} color={c.textMuted} />
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{t('sharing.title', 'Share "{name}"', { name: calendar.name })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||
{sharedEntries.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>{t('calendar.share.shared_with', 'Shared with')}</Text>
|
||||
{sharedEntries.map(([principalId, rights]) => {
|
||||
const preset = detectPreset(rights);
|
||||
const busy = savingId === principalId;
|
||||
return (
|
||||
<View key={principalId} style={styles.shareRow}>
|
||||
{renderPrincipalLabel(principalId)}
|
||||
<View style={styles.roleChips}>
|
||||
{PRESET_ORDER.map((p) => (
|
||||
<Pressable
|
||||
key={p}
|
||||
onPress={() => void applyShare(principalId, CALENDAR_PRESETS[p])}
|
||||
disabled={busy}
|
||||
style={[styles.chip, preset === p && styles.chipActive]}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: preset === p, disabled: busy }}
|
||||
>
|
||||
<Text style={[styles.chipText, preset === p && styles.chipTextActive]}>
|
||||
{t(PRESET_LABEL_KEYS[p][0], PRESET_LABEL_KEYS[p][1])}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{preset === 'custom' ? (
|
||||
<Text style={styles.customLabel}>{t('sharing.preset.custom', 'Custom')}</Text>
|
||||
) : null}
|
||||
<Pressable
|
||||
onPress={() => void applyShare(principalId, null)}
|
||||
disabled={busy}
|
||||
hitSlop={8}
|
||||
style={styles.removeBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('sharing.remove', 'Remove access')}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<Trash2 size={16} color={c.error} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>{t('calendar.share.add_people', 'Add people')}</Text>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder={t('calendar.share.search_placeholder', 'Search by name or email')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.input}
|
||||
/>
|
||||
{noticeText ? (
|
||||
<Text style={styles.empty} accessibilityLiveRegion="polite">{noticeText}</Text>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} style={{ marginTop: spacing.md }} />
|
||||
) : candidates.length === 0 ? (
|
||||
notice && !search.trim() ? null : (
|
||||
<Text style={styles.empty}>
|
||||
{principals.length === 0 && !notice
|
||||
? t('calendar.share.no_principals', 'Sharing is not available on this server.')
|
||||
: t('calendar.share.no_matches', 'No matches')}
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
candidates.map((p) => (
|
||||
<Pressable
|
||||
key={p.id}
|
||||
onPress={() => void applyShare(p.id, CALENDAR_PRESETS.read)}
|
||||
disabled={!!savingId}
|
||||
style={({ pressed }) => [styles.candidateRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
{renderPrincipalLabel(p.id)}
|
||||
{savingId === p.id ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<UserPlus size={18} color={c.primary} />
|
||||
)}
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<Pressable onPress={onClose} style={styles.doneBtn}>
|
||||
<Text style={styles.doneText}>{t('common.done', 'Done')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
<ShareCollectionSheet
|
||||
collection={calendar}
|
||||
presets={presets}
|
||||
addRights={CALENDAR_PRESETS.read}
|
||||
onShare={onShare}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ? <View pointerEvents="none" style={[styles.startBar, { backgroundColor: colors.bar }]} /> : null}
|
||||
{inactive && (
|
||||
// The calendar-coloured outline sits inside the ring.
|
||||
<View
|
||||
@@ -143,10 +145,12 @@ export function AllDayEventBar({
|
||||
styles.bar,
|
||||
corners('horizontal', continuesBefore, continuesAfter, EVENT_RADIUS),
|
||||
{ backgroundColor: colors.fill },
|
||||
colors.bar ? styles.barWithBar : null,
|
||||
inactive && [styles.barInactive, { borderColor: colors.border ?? undefined }],
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{colors.bar ? <View pointerEvents="none" style={[styles.startBar, { backgroundColor: colors.bar }]} /> : null}
|
||||
<Text style={[styles.barTitle, { color: colors.text }]} numberOfLines={1}>
|
||||
{continuesBefore ? '… ' : ''}
|
||||
<Text style={inactive && styles.struck}>{title}</Text>
|
||||
@@ -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' },
|
||||
|
||||
@@ -558,6 +558,7 @@ export function EventModal({
|
||||
automaticallyAdjustKeyboardInsets
|
||||
>
|
||||
<TextInput
|
||||
accessibilityLabel={t('calendar.form.title', 'Title')}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder={t('calendar.form.title', 'Title')}
|
||||
@@ -797,6 +798,7 @@ export function EventModal({
|
||||
{customReminderOpen && (
|
||||
<View style={styles.customReminderRow}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('calendar.alerts.amount', 'Reminder amount')}
|
||||
value={customAmount}
|
||||
onChangeText={(v) => setCustomAmount(v.replace(/[^0-9]/g, ''))}
|
||||
keyboardType="number-pad"
|
||||
@@ -834,6 +836,7 @@ export function EventModal({
|
||||
|
||||
<Section icon={<MapPin size={16} color={c.textMuted} />} label={t('calendar.form.location', 'Location')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('calendar.form.location', 'Location')}
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
|
||||
@@ -844,6 +847,7 @@ export function EventModal({
|
||||
|
||||
<Section icon={<Video size={16} color={c.textMuted} />} label={t('calendar.form.meeting_link', 'Meeting link')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('calendar.form.meeting_link', 'Meeting link')}
|
||||
value={videoUrl}
|
||||
onChangeText={setVideoUrl}
|
||||
placeholder="https://meet.example.com/…"
|
||||
@@ -860,6 +864,7 @@ export function EventModal({
|
||||
label={t('calendar.form.description', 'Description')}
|
||||
>
|
||||
<TextInput
|
||||
accessibilityLabel={t('calendar.form.description', 'Description')}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
key={`${event.id}-${idx}`}
|
||||
style={[
|
||||
styles.chip,
|
||||
{ backgroundColor: colors.fill },
|
||||
colors.bar ? { borderStartWidth: TINTED_BAR_WIDTH, borderStartColor: colors.bar } : null,
|
||||
colors.border !== null && [styles.chipInactive, { borderColor: colors.border }],
|
||||
]}
|
||||
>
|
||||
@@ -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 (
|
||||
<View style={[styles.fullWeekRow, { height }]}>
|
||||
@@ -365,7 +371,7 @@ function FullWeekRow({
|
||||
</View>
|
||||
{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 }],
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -94,6 +94,7 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
accessibilityLabel={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
|
||||
// No full-screen keyboard in landscape: it would hide the suggestions
|
||||
// and the text around the field.
|
||||
disableFullscreenUI
|
||||
|
||||
@@ -282,6 +282,7 @@ export function TasksSheet({
|
||||
|
||||
<View style={styles.addRow}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('calendar.tasks.title_placeholder', 'Task title')}
|
||||
value={editor.title}
|
||||
onChangeText={(v) => 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 && (
|
||||
<View style={styles.editor}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('calendar.tasks.description_placeholder', 'Notes')}
|
||||
value={editor.description}
|
||||
onChangeText={(v) => setEditor((e) => ({ ...e, description: v }))}
|
||||
placeholder={t('calendar.tasks.description_placeholder', 'Notes')}
|
||||
|
||||
@@ -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 {
|
||||
@@ -37,7 +38,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 +49,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;
|
||||
@@ -117,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(
|
||||
@@ -174,16 +180,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<CalendarWeekSegment[]>(
|
||||
() => 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 +372,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 +425,13 @@ function TimeGridScrollViewInner({
|
||||
<Text style={styles.allDayLabel} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.6}>
|
||||
{t('calendar.events.all_day', 'All day')}
|
||||
</Text>
|
||||
{allDayRows > shownAllDayRows(allDayRows, false) ? (
|
||||
<AllDayToggle
|
||||
hiddenRows={allDayRows - shownAllDayRows(allDayRows, false)}
|
||||
expanded={allDayExpanded}
|
||||
onToggle={() => setAllDayExpanded((v) => !v)}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -437,6 +451,7 @@ function TimeGridScrollViewInner({
|
||||
getEventColor(segment.event, calendars),
|
||||
isInactiveEvent(segment.event, currentUserEmails),
|
||||
c,
|
||||
tinted,
|
||||
)}
|
||||
continuesBefore={segment.continuesBefore}
|
||||
continuesAfter={segment.continuesAfter}
|
||||
@@ -595,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));
|
||||
@@ -628,6 +645,7 @@ const DayColumn = React.memo(function DayColumn({
|
||||
getEventColor(event, calendars),
|
||||
isInactiveEvent(event, currentUserEmails),
|
||||
colors,
|
||||
tinted,
|
||||
)}
|
||||
ringColor={colors.background}
|
||||
continuesBefore={continuesBefore}
|
||||
|
||||
@@ -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 {
|
||||
@@ -21,13 +22,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;
|
||||
@@ -65,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<ScrollView>(null);
|
||||
@@ -91,12 +95,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 +189,13 @@ function WeekViewInner({
|
||||
<View style={[styles.allDayStrip, { height: allDayStripHeight }]}>
|
||||
<View style={[styles.gutter, styles.allDayLabelWrap]}>
|
||||
<Text style={styles.allDayLabel} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.6}>{t('calendar.events.all_day', 'All day')}</Text>
|
||||
{allDayHiddenRows > 0 ? (
|
||||
<AllDayToggle
|
||||
hiddenRows={allDayHiddenRows}
|
||||
expanded={allDayExpanded}
|
||||
onToggle={() => setAllDayExpanded((v) => !v)}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.allDayGrid}>
|
||||
{/* Empty per-day columns to draw vertical separators */}
|
||||
@@ -189,7 +204,7 @@ function WeekViewInner({
|
||||
))}
|
||||
{/* Segments overlay - multi-day events span their date range */}
|
||||
<View style={styles.allDayOverlay} pointerEvents="box-none">
|
||||
{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 (
|
||||
@@ -200,6 +215,7 @@ function WeekViewInner({
|
||||
getEventColor(segment.event, calendars),
|
||||
isInactiveEvent(segment.event, currentUserEmails),
|
||||
c,
|
||||
tinted,
|
||||
)}
|
||||
continuesBefore={segment.continuesBefore}
|
||||
continuesAfter={segment.continuesAfter}
|
||||
@@ -275,6 +291,7 @@ function WeekViewInner({
|
||||
getEventColor(event, calendars),
|
||||
isInactiveEvent(event, currentUserEmails),
|
||||
c,
|
||||
tinted,
|
||||
)}
|
||||
ringColor={c.background}
|
||||
continuesBefore={continuesBefore}
|
||||
|
||||
@@ -128,6 +128,7 @@ export default function AddressBookPickerSheet({
|
||||
{creating ? (
|
||||
<View style={styles.createRow}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.address_books.name_label', 'Address book name')}
|
||||
style={styles.createInput}
|
||||
placeholder={t('contacts.address_books.name_label', 'Address book name')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
|
||||
@@ -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<string, AddressBookRights>;
|
||||
|
||||
interface AddressBookShareSheetProps {
|
||||
/** Null hides the sheet. */
|
||||
book: AddressBook | null;
|
||||
onShare: (bookId: string, principalId: string, rights: AddressBookRights | null) => Promise<void>;
|
||||
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<SharePreset<AddressBookRights>[]>(() => [
|
||||
{ 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 (
|
||||
<ShareCollectionSheet
|
||||
collection={book}
|
||||
presets={presets}
|
||||
addRights={ADDRESS_BOOK_PRESETS.read}
|
||||
onShare={onShare}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -140,6 +140,7 @@ export default function ContactPickerSheet({
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={16} color={c.textMuted} />
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.search_placeholder', 'Search contacts...')}
|
||||
style={styles.searchInput}
|
||||
placeholder={t('contacts.search_placeholder', 'Search contacts...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
|
||||
@@ -282,6 +282,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
<View key={kw.keyword} style={styles.renameRow}>
|
||||
<Tag size={16} color={c.textSecondary} />
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.rename_category', 'Rename category')}
|
||||
style={styles.renameInput}
|
||||
value={renameValue}
|
||||
onChangeText={setRenameValue}
|
||||
|
||||
@@ -94,6 +94,7 @@ export default function TagAssignSheet({
|
||||
|
||||
<View style={styles.inputRow}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.tag_name_placeholder', 'New tag name')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.tag_name_placeholder', 'New tag name')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
|
||||
@@ -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({
|
||||
<X size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.title} numberOfLines={1}>{item?.name ?? title ?? ''}</Text>
|
||||
{headerActions}
|
||||
{item && !showsFallback ? (
|
||||
<Pressable
|
||||
onPress={onOpenExternal}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet } from 'react-native';
|
||||
import type { FileNode } from '../../api/types';
|
||||
import { AttachmentPreviewModal } from '../email/AttachmentPreviewModal';
|
||||
import { previewKindOf, useFilePreview } from '../email/use-file-preview';
|
||||
import { ChevronLeft, ChevronRight } from '../icons';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
export type PreviewableFileNode = Pick<FileNode, 'name' | 'type' | 'size' | 'blobId' | 'accountId'>;
|
||||
|
||||
@@ -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 <AttachmentPreviewModal {...preview.modalProps} docked={docked} />;
|
||||
const headerActions = onPrevious || onNext ? (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={onPrevious}
|
||||
disabled={!onPrevious}
|
||||
style={styles.button}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('files.previous_file', 'Previous file')}
|
||||
accessibilityState={{ disabled: !onPrevious }}
|
||||
>
|
||||
<ChevronLeft size={22} color={onPrevious ? c.text : c.textMuted} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onNext}
|
||||
disabled={!onNext}
|
||||
style={styles.button}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('files.next_file', 'Next file')}
|
||||
accessibilityState={{ disabled: !onNext }}
|
||||
>
|
||||
<ChevronRight size={22} color={onNext ? c.text : c.textMuted} />
|
||||
</Pressable>
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
return <AttachmentPreviewModal {...preview.modalProps} docked={docked} headerActions={headerActions} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// The preview header's own button size.
|
||||
button: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
|
||||
@@ -274,6 +274,7 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>{t('files.share_add_people', 'Add people')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabel={t('sharing.add_person', 'Add person or group')}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder={t('files.share_search_placeholder', 'Search by name or email')}
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.tinted_events', 'Light event colors')}
|
||||
description={t(
|
||||
'calendar.settings.tinted_events_desc',
|
||||
'Show events as a light tint of their calendar color with a colored bar at the start, as in Outlook and on printouts, instead of solid blocks.',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={tintedEvents}
|
||||
onChange={(v) => update('calendarTintedEvents', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_week_numbers', 'Show week numbers')}
|
||||
description={t('calendar.settings.show_week_numbers_mobile_desc', 'Display week numbers in the month view.')}
|
||||
|
||||
@@ -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<AddressBook | null>(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')}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
{/* Own books the user may share (webmail address-book settings). */}
|
||||
{!book.isShared && book.myRights?.mayShare ? (
|
||||
<Pressable
|
||||
onPress={() => setShareTarget(book)}
|
||||
hitSlop={6}
|
||||
style={styles.iconBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('contacts.address_books.share', 'Share address book')}
|
||||
>
|
||||
<Users
|
||||
size={15}
|
||||
color={Object.keys(book.shareWith ?? {}).length > 0 ? c.primary : c.textSecondary}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{!book.isShared && (
|
||||
<Pressable
|
||||
onPress={() => { if (!book.isDefault) void handleSetDefault(book.id); }}
|
||||
@@ -392,6 +412,12 @@ export function ContactsSettings() {
|
||||
onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }}
|
||||
/>
|
||||
|
||||
<AddressBookShareSheet
|
||||
book={shareTarget}
|
||||
onShare={shareAddressBook}
|
||||
onClose={() => setShareTarget(null)}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={deleteTarget !== null}
|
||||
title={t('contacts.address_books.delete', 'Delete address book')}
|
||||
|
||||
@@ -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<AudienceChoice>(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
|
||||
@@ -327,6 +331,7 @@ export function VacationSettings() {
|
||||
description={t('settings.vacation.date_range.start_description', 'Leave empty for no start limit')}
|
||||
>
|
||||
<TextInput
|
||||
accessibilityLabel={t('settings.vacation.date_range.start', 'Start Date')}
|
||||
value={fromDate}
|
||||
onChangeText={setFromDate}
|
||||
placeholder="YYYY-MM-DD HH:MM"
|
||||
@@ -341,6 +346,7 @@ export function VacationSettings() {
|
||||
description={t('settings.vacation.date_range.end_description', 'Leave empty for no end limit')}
|
||||
>
|
||||
<TextInput
|
||||
accessibilityLabel={t('settings.vacation.date_range.end', 'End Date')}
|
||||
value={toDate}
|
||||
onChangeText={setToDate}
|
||||
placeholder="YYYY-MM-DD HH:MM"
|
||||
@@ -375,6 +381,7 @@ export function VacationSettings() {
|
||||
<Text style={styles.bodyLabel}>{t('settings.vacation.message.body_label', 'Message Body')}</Text>
|
||||
<Text style={styles.bodyDesc}>{t('settings.vacation.message.body_description', 'Plain text message content')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabel={t('settings.vacation.message.body_label', 'Message Body')}
|
||||
value={body}
|
||||
onChangeText={setBody}
|
||||
placeholder={t('settings.vacation.message.body_placeholder', 'Thank you for your email. I am currently out of the office and will respond when I return.')}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
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 { Principal } from '../../api/types';
|
||||
import { directoryNotice } from '../../lib/principal-directory';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
|
||||
/** A role the sheet offers: its rights and the webmail's name for it. */
|
||||
export interface SharePreset<R> {
|
||||
key: string;
|
||||
label: string;
|
||||
rights: R;
|
||||
}
|
||||
|
||||
/** The preset whose rights `rights` match, or 'custom'. */
|
||||
export function detectSharePreset<R extends object>(rights: R, presets: SharePreset<R>[]): 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<R> {
|
||||
id: string;
|
||||
name: string;
|
||||
shareWith?: Record<string, R> | null;
|
||||
}
|
||||
|
||||
interface ShareCollectionSheetProps<R extends object> {
|
||||
/** Null hides the sheet. */
|
||||
collection: ShareableCollection<R> | null;
|
||||
presets: SharePreset<R>[];
|
||||
/** What a new share gets (the webmail: read). */
|
||||
addRights: R;
|
||||
onShare: (collectionId: string, principalId: string, rights: R | null) => Promise<void>;
|
||||
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<R extends object>({
|
||||
collection, presets, addRights, onShare, onClose,
|
||||
}: ShareCollectionSheetProps<R>) {
|
||||
// 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<Principal[]>([]);
|
||||
const [directory, setDirectory] = React.useState<Omit<PrincipalListing, 'principals'>>({ status: 'ok' });
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [shares, setShares] = React.useState<Record<string, R>>({});
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [savingId, setSavingId] = React.useState<string | null>(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<string, Principal>();
|
||||
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 (
|
||||
<View style={styles.principalInfo}>
|
||||
<Text style={styles.principalName} numberOfLines={1}>
|
||||
{p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)}
|
||||
</Text>
|
||||
{p?.email || unresolved ? (
|
||||
<Text style={styles.principalEmail} numberOfLines={1}>{p?.email || principalId}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.titleRow}>
|
||||
<Users size={18} color={c.textMuted} />
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{t('sharing.title', 'Share "{name}"', { name: collection.name })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||
{sharedEntries.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>{t('calendar.share.shared_with', 'Shared with')}</Text>
|
||||
{sharedEntries.map(([principalId, rights]) => {
|
||||
const preset = detectSharePreset(rights, presets);
|
||||
const busy = savingId === principalId;
|
||||
return (
|
||||
<View key={principalId} style={styles.shareRow}>
|
||||
{renderPrincipalLabel(principalId)}
|
||||
<View style={styles.roleChips}>
|
||||
{presets.map((p) => (
|
||||
<Pressable
|
||||
key={p.key}
|
||||
onPress={() => void applyShare(principalId, p.rights)}
|
||||
disabled={busy}
|
||||
style={[styles.chip, preset === p.key && styles.chipActive]}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: preset === p.key, disabled: busy }}
|
||||
>
|
||||
<Text style={[styles.chipText, preset === p.key && styles.chipTextActive]}>
|
||||
{p.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{preset === 'custom' ? (
|
||||
<Text style={styles.customLabel}>{t('sharing.preset.custom', 'Custom')}</Text>
|
||||
) : null}
|
||||
<Pressable
|
||||
onPress={() => void applyShare(principalId, null)}
|
||||
disabled={busy}
|
||||
hitSlop={8}
|
||||
style={styles.removeBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('sharing.remove', 'Remove access')}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<Trash2 size={16} color={c.error} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>{t('calendar.share.add_people', 'Add people')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabel={t('sharing.add_person', 'Add person or group')}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder={t('calendar.share.search_placeholder', 'Search by name or email')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.input}
|
||||
/>
|
||||
{noticeText ? (
|
||||
<Text style={styles.empty} accessibilityLiveRegion="polite">{noticeText}</Text>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} style={{ marginTop: spacing.md }} />
|
||||
) : candidates.length === 0 ? (
|
||||
notice && !search.trim() ? null : (
|
||||
<Text style={styles.empty}>
|
||||
{principals.length === 0 && !notice
|
||||
? t('calendar.share.no_principals', 'Sharing is not available on this server.')
|
||||
: t('calendar.share.no_matches', 'No matches')}
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
candidates.map((p) => (
|
||||
<Pressable
|
||||
key={p.id}
|
||||
onPress={() => void applyShare(p.id, addRights)}
|
||||
disabled={!!savingId}
|
||||
style={({ pressed }) => [styles.candidateRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
{renderPrincipalLabel(p.id)}
|
||||
{savingId === p.id ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<UserPlus size={18} color={c.primary} />
|
||||
)}
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<Pressable onPress={onClose} style={styles.doneBtn}>
|
||||
<Text style={styles.doneText}>{t('common.done', 'Done')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
|
||||
sheet: {
|
||||
backgroundColor: c.background,
|
||||
borderTopLeftRadius: radius.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 },
|
||||
});
|
||||
}
|
||||
@@ -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(/<AllDayToggle/);
|
||||
const week = read('WeekView.tsx');
|
||||
expect(week).toMatch(/return packAllDaySegments\(\[\.\.\.explicit, \.\.\.timedFull\]\);/);
|
||||
expect(week).toMatch(/allDaySegments\.filter\(\(segment\) => segment\.row < allDayRowCount\)/);
|
||||
expect(week).toMatch(/<AllDayToggle/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { eventBlockColors, mixColors } from '../event-colors';
|
||||
import { mergeWithDefaults } from '../../stores/settings-store';
|
||||
|
||||
// The webmail's "Light event colors" (calendarTintedEvents) had no
|
||||
// counterpart on the phone (file 10 "light tinted colours"). Webmail
|
||||
// event-card eventFillStyle: color-mix(color 20%, background), foreground
|
||||
// text, a calendar-coloured bar at the start.
|
||||
|
||||
const page = { background: '#ffffff', text: '#111111' };
|
||||
const read = (rel: string) => 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 \? <View pointerEvents="none" style=\{\[styles\.startBar/g)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -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<FileNode> = {}) =>
|
||||
({ id, name, parentId, blobId: null, ...extra }) as FileNode;
|
||||
const file = (id: string, name: string, parentId: string | null = null, extra: Partial<FileNode> = {}) =>
|
||||
({ 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'\)/);
|
||||
});
|
||||
});
|
||||
@@ -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(/<Text style=\{styles\.title\}[^\n]*<\/Text>\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);
|
||||
});
|
||||
});
|
||||
@@ -103,7 +103,65 @@ export function buildAllDaySegments(
|
||||
raw.push({ ...segment, startIndex: segment.startIndex + from });
|
||||
}
|
||||
}
|
||||
return packWeekSegments(raw);
|
||||
return packAllDaySegments(raw);
|
||||
}
|
||||
|
||||
/** The id prefix CalendarScreen gives the due tasks it draws on the grid. */
|
||||
export const TASK_CHIP_ID_PREFIX = 'task:';
|
||||
|
||||
/**
|
||||
* Packs the all-day bars into rows, with each day's task chips stacked under
|
||||
* that day's all-day events rather than packed in among them (webmail
|
||||
* calendar-week-view, #1107, #1270, #1122).
|
||||
*/
|
||||
export function packAllDaySegments(raw: CalendarWeekSegment[]): CalendarWeekSegment[] {
|
||||
const isTask = (s: CalendarWeekSegment) => 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<number, number>();
|
||||
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. */
|
||||
|
||||
+17
-2
@@ -149,17 +149,28 @@ export interface EventBlockColors {
|
||||
text: string;
|
||||
/** Inset border in the calendar colour, for declined and cancelled events only. */
|
||||
border: string | null;
|
||||
/** The light style's bar along the start edge, in the calendar colour; null without it. */
|
||||
bar?: string | null;
|
||||
}
|
||||
|
||||
/** Share of the calendar colour in the light style's fill (webmail color-mix 20%). */
|
||||
export const TINTED_FILL_MIX = 0.2;
|
||||
/** Width of the light style's bar (webmail --start-bar-width). */
|
||||
export const TINTED_BAR_WIDTH = 3;
|
||||
|
||||
/**
|
||||
* Fill, label and border of an event block, bar or chip. Active events are
|
||||
* solid calendar colour with a computed label colour; declined and cancelled
|
||||
* ones sit on the page ground inside a calendar-coloured border.
|
||||
* solid calendar colour with a computed label colour, or with "Light event
|
||||
* colors" (`tinted`) a 20% tint of it on the page ground with the text
|
||||
* colour and a calendar-coloured bar at the start (webmail eventFillStyle);
|
||||
* declined and cancelled ones sit on the page ground inside a
|
||||
* calendar-coloured border either way.
|
||||
*/
|
||||
export function eventBlockColors(
|
||||
calendarColor: string,
|
||||
inactive: boolean,
|
||||
page: { background: string; text: string },
|
||||
tinted = false,
|
||||
): EventBlockColors {
|
||||
if (inactive) {
|
||||
return {
|
||||
@@ -168,5 +179,9 @@ export function eventBlockColors(
|
||||
border: calendarColor,
|
||||
};
|
||||
}
|
||||
if (tinted) {
|
||||
const fill = mixColors(calendarColor, page.background, TINTED_FILL_MIX);
|
||||
if (fill) return { fill, text: page.text, border: null, bar: calendarColor };
|
||||
}
|
||||
return { fill: calendarColor, text: readableTextOn(calendarColor), border: null };
|
||||
}
|
||||
|
||||
@@ -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<string>): 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<string>): RecentFile[] {
|
||||
const next = recent.filter((r) => existingIds.has(r.id));
|
||||
return next.length === recent.length ? recent : next;
|
||||
}
|
||||
@@ -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<T extends { id: string }>(
|
||||
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] };
|
||||
}
|
||||
@@ -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 \}/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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/<view>/<yyyy-mm-dd>` 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/);
|
||||
});
|
||||
});
|
||||
@@ -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/<id>/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';/);
|
||||
});
|
||||
});
|
||||
@@ -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/<folder>` and `?preview=` links opened only the Files tab (file 10
|
||||
// "deep links" on the Files row, 07 "Deep link /files/<folder> 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\);/);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
+80
-10
@@ -8,8 +8,15 @@ 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 { 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';
|
||||
@@ -38,11 +45,18 @@ 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 }
|
||||
| { kind: 'contact'; contactId: string }
|
||||
// account it belongs to (`?appAccount=`, from widgets). `view` and `date`
|
||||
// (`YYYY-MM-DD`): the view and day a `/calendar/<view>/<date>` link shows.
|
||||
| { kind: 'calendar'; eventId?: string; view?: CalendarLinkView; date?: string; jmapAccountId?: string; accountId?: string }
|
||||
// `edit`: `/contacts/<id>/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' }
|
||||
// `path`: a folder (`/files/<folder>/<sub>`); `preview`: a file in it to
|
||||
// open (`?preview=`); `shared`: a node of a drive shared with the user
|
||||
// (`?shared=<owner>:<id>`). 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 };
|
||||
|
||||
@@ -149,15 +163,51 @@ 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/<view>[/<date>]`, or a bare `/calendar/<date>` 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) };
|
||||
// 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':
|
||||
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': {
|
||||
@@ -298,16 +348,36 @@ 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;
|
||||
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);
|
||||
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':
|
||||
|
||||
@@ -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/<view>/<yyyy-mm-dd>` 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<PendingCalendarDateState>((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);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/** A `/files/<folder path>[?preview=<name>]` 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 (`<owner>:<id>`): 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<PendingFilesLinkState>((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);
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -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';
|
||||
@@ -335,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',
|
||||
@@ -516,6 +521,42 @@ export default function CalendarScreen() {
|
||||
[viewMode, visibleDate, focus.date],
|
||||
);
|
||||
|
||||
// A `/calendar/<view>/<date>` 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<DrawerViewOption[]>(
|
||||
() => [
|
||||
@@ -582,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;
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ export default function ContactFormScreen() {
|
||||
const route = useRoute<Route>();
|
||||
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/<id>/edit`) does not know it names a group.
|
||||
const asGroup = asGroupParam || existing?.kind === 'group';
|
||||
const existingKeywords = React.useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
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<number | null>(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()) {
|
||||
@@ -519,6 +538,7 @@ export default function ContactFormScreen() {
|
||||
onRemove={() => updateForm('notes', form.notes.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.note', 'Notes')}
|
||||
style={[styles.input, styles.multiline]}
|
||||
placeholder={t('contacts.form.note_placeholder', 'Add a note...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -561,6 +581,7 @@ export default function ContactFormScreen() {
|
||||
</View>
|
||||
)}
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.categories', 'Categories')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.categories_placeholder_mobile', 'Add tag and press Enter')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -647,6 +668,28 @@ export default function ContactFormScreen() {
|
||||
/>
|
||||
);
|
||||
|
||||
if (isEdit && !existing) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={handleBack}
|
||||
style={styles.headerBtn}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.back', 'Back')}
|
||||
>
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{t('contacts.contact', 'Contact')}</Text>
|
||||
</View>
|
||||
<View style={styles.missing}>
|
||||
<Text style={styles.missingText}>{t('contacts.detail.not_found', 'Contact not found')}</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (asGroup) {
|
||||
// Groups are a name plus a member list - none of the person fields apply.
|
||||
return (
|
||||
@@ -662,6 +705,7 @@ export default function ContactFormScreen() {
|
||||
<Section icon={<Users size={16} color={c.textMuted} />} label={t('contacts.group', 'Group')}>
|
||||
<Field label={t('contacts.groups.name_label', 'Group Name')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.groups.name_label', 'Group Name')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.groups.name_placeholder', 'e.g., Team, Family')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -774,6 +818,7 @@ export default function ContactFormScreen() {
|
||||
{form.isOrg ? (
|
||||
<Field label={t('contacts.form.organization', 'Organization')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.organization', 'Organization')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.organization_placeholder', 'Company name')}
|
||||
ref={orgRef}
|
||||
@@ -794,6 +839,7 @@ export default function ContactFormScreen() {
|
||||
<View style={styles.row2}>
|
||||
<Field label={t('contacts.form.prefix', 'Prefix')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.prefix', 'Prefix')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.prefix_placeholder', 'Dr., Mr., Mrs.')}
|
||||
ref={prefixRef}
|
||||
@@ -807,6 +853,7 @@ export default function ContactFormScreen() {
|
||||
</Field>
|
||||
<Field label={t('contacts.form.suffix', 'Suffix')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.suffix', 'Suffix')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.suffix_placeholder', 'Jr., Sr., III')}
|
||||
ref={suffixRef}
|
||||
@@ -821,6 +868,7 @@ export default function ContactFormScreen() {
|
||||
</View>
|
||||
<Field label={t('contacts.form.given_name', 'First name')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.given_name', 'First name')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.given_name', 'First name')}
|
||||
ref={givenRef}
|
||||
@@ -835,6 +883,7 @@ export default function ContactFormScreen() {
|
||||
</Field>
|
||||
<Field label={t('contacts.form.middle_name', 'Middle name')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.middle_name', 'Middle name')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.middle_name', 'Middle name')}
|
||||
ref={middleRef}
|
||||
@@ -848,6 +897,7 @@ export default function ContactFormScreen() {
|
||||
</Field>
|
||||
<Field label={t('contacts.form.surname', 'Last name')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.surname', 'Last name')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.surname', 'Last name')}
|
||||
ref={surnameRef}
|
||||
@@ -864,6 +914,7 @@ export default function ContactFormScreen() {
|
||||
<Field key={i} label={i === 0 ? t('contacts.form.nickname', 'Nickname') : undefined}>
|
||||
<View style={styles.dateInputRow}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.nickname', 'Nickname')}
|
||||
style={[styles.input, { flex: 1 }]}
|
||||
placeholder={t('contacts.form.nickname_placeholder', 'Nickname')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -895,6 +946,7 @@ export default function ContactFormScreen() {
|
||||
)}
|
||||
<Field label={t('contacts.form.display_name_optional', 'Display name (optional)')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.display_name_optional', 'Display name (optional)')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.display_name_placeholder', 'Custom display name')}
|
||||
ref={fullRef}
|
||||
@@ -916,6 +968,7 @@ export default function ContactFormScreen() {
|
||||
onRemove={() => updateForm('emails', form.emails.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.email', 'Email')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.email_placeholder', 'email@example.com')}
|
||||
ref={i === 0 ? firstEmailRef : undefined}
|
||||
@@ -955,6 +1008,7 @@ export default function ContactFormScreen() {
|
||||
onRemove={() => updateForm('phones', form.phones.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.phone', 'Phone')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.phone_placeholder', '+1 234 567 890')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1007,6 +1061,7 @@ export default function ContactFormScreen() {
|
||||
{!(form.isOrg && i === 0) && (
|
||||
<Field label={t('contacts.form.organization', 'Organization')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.organization', 'Organization')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.organization_placeholder', 'Company name')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1021,6 +1076,7 @@ export default function ContactFormScreen() {
|
||||
)}
|
||||
<Field label={t('contacts.form.department', 'Department')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.department', 'Department')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.department_placeholder', 'Department')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1034,6 +1090,7 @@ export default function ContactFormScreen() {
|
||||
</Field>
|
||||
<Field label={t('contacts.form.job_title', 'Job title')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.job_title', 'Job title')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.job_title_placeholder', 'e.g., Software Engineer')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1047,6 +1104,7 @@ export default function ContactFormScreen() {
|
||||
</Field>
|
||||
<Field label={t('contacts.form.role', 'Role')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.role', 'Role')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.role_placeholder', 'e.g., Team Lead')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1079,6 +1137,7 @@ export default function ContactFormScreen() {
|
||||
onRemove={() => updateForm('addresses', form.addresses.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.street', 'Street')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.street', 'Street')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1092,6 +1151,7 @@ export default function ContactFormScreen() {
|
||||
<View style={styles.row2}>
|
||||
<Field>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.city', 'City')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.city', 'City')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1105,6 +1165,7 @@ export default function ContactFormScreen() {
|
||||
</Field>
|
||||
<Field>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.region', 'State / Region')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.region', 'State / Region')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1120,6 +1181,7 @@ export default function ContactFormScreen() {
|
||||
<View style={styles.row2}>
|
||||
<Field>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.postcode', 'Postal code')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.postcode', 'Postal code')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1133,6 +1195,7 @@ export default function ContactFormScreen() {
|
||||
</Field>
|
||||
<Field>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.country', 'Country')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.country', 'Country')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1180,6 +1243,7 @@ export default function ContactFormScreen() {
|
||||
>
|
||||
<Field label={t('contacts.form.url', 'URL')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.url', 'URL')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.url_placeholder', 'https://...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1195,6 +1259,7 @@ export default function ContactFormScreen() {
|
||||
<View style={styles.row2}>
|
||||
<Field label={t('contacts.form.service_placeholder', 'Service')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.service_placeholder', 'Service')}
|
||||
style={styles.input}
|
||||
placeholder="LinkedIn, Mastodon, …"
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1208,6 +1273,7 @@ export default function ContactFormScreen() {
|
||||
</Field>
|
||||
<Field label={t('contacts.form.label', 'Label')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.label', 'Label')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.label_placeholder', 'Work, Personal, …')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1242,6 +1308,7 @@ export default function ContactFormScreen() {
|
||||
>
|
||||
<View style={styles.dateInputRow}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.anniversary_date', 'Date')}
|
||||
style={[
|
||||
styles.input,
|
||||
{ flex: 1 },
|
||||
@@ -1296,6 +1363,7 @@ export default function ContactFormScreen() {
|
||||
onRemove={() => updateForm('personalInfo', form.personalInfo.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.personal_info', 'Personal info')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.personal_info_placeholder', 'e.g., Photography')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1356,6 +1424,7 @@ export default function ContactFormScreen() {
|
||||
</Field>
|
||||
<Field label={t('contacts.form.pronouns', 'Pronouns')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.pronouns', 'Pronouns')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.pronouns_placeholder', 'they/them')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1375,6 +1444,7 @@ export default function ContactFormScreen() {
|
||||
>
|
||||
<Field label={t('contacts.form.calendar_uri', 'Calendar URL')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.calendar_uri', 'Calendar URL')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.url_placeholder', 'https://...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1385,6 +1455,7 @@ export default function ContactFormScreen() {
|
||||
</Field>
|
||||
<Field label={t('contacts.form.scheduling_uri', 'Scheduling URL')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.scheduling_uri', 'Scheduling URL')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.url_placeholder', 'https://...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1395,6 +1466,7 @@ export default function ContactFormScreen() {
|
||||
</Field>
|
||||
<Field label={t('contacts.form.freebusy_uri', 'Free/Busy URL')}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.form.freebusy_uri', 'Free/Busy URL')}
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.url_placeholder', 'https://...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
@@ -1477,6 +1549,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,
|
||||
|
||||
@@ -530,6 +530,7 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={16} color={c.textMuted} />
|
||||
<TextInput
|
||||
accessibilityLabel={t('contacts.search_placeholder', 'Search contacts...')}
|
||||
style={styles.searchInput}
|
||||
placeholder={t('contacts.search_placeholder', 'Search contacts...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
|
||||
+309
-17
@@ -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';
|
||||
@@ -84,6 +84,12 @@ 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 {
|
||||
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.
|
||||
@@ -265,6 +271,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<FilesViewMode | null>(null);
|
||||
const viewMode: FilesViewMode = viewOverride ?? defaultViewMode;
|
||||
@@ -298,6 +307,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);
|
||||
@@ -305,7 +315,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) {
|
||||
@@ -450,10 +466,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);
|
||||
@@ -462,26 +501,33 @@ 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.
|
||||
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;
|
||||
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
|
||||
@@ -552,7 +598,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 +612,7 @@ export default function FilesScreen() {
|
||||
if (isSymlink(row)) return;
|
||||
openFile(row);
|
||||
},
|
||||
[selectionMode, toggleSelect, openFile],
|
||||
[selectionMode, toggleSelect, openFile, openPreview],
|
||||
);
|
||||
|
||||
const handleRowLongPress = useCallback((row: FileRow) => {
|
||||
@@ -652,13 +698,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).
|
||||
@@ -685,15 +737,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<string | null>) => {
|
||||
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();
|
||||
@@ -793,9 +861,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));
|
||||
@@ -811,7 +881,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;
|
||||
@@ -830,7 +900,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,
|
||||
@@ -840,7 +914,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] : []));
|
||||
@@ -1018,6 +1092,7 @@ export default function FilesScreen() {
|
||||
<View style={styles.toolbarRow}>
|
||||
{searchOpen ? (
|
||||
<TextInput
|
||||
accessibilityLabel={t('files.search_placeholder', 'Search files...')}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
placeholder={t('files.search_placeholder', 'Search files...')}
|
||||
@@ -1221,6 +1296,167 @@ 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) ? (
|
||||
<View style={styles.places}>
|
||||
{starredFolders.length > 0 ? (
|
||||
<Text style={styles.placesTitle} accessibilityRole="header">{t('files.favorites', 'Starred')}</Text>
|
||||
) : null}
|
||||
{starredFolders.map(({ path: folderPath, stack }) => {
|
||||
const name = stack[stack.length - 1].name;
|
||||
return (
|
||||
<Pressable
|
||||
key={folderPath}
|
||||
onPress={() => openFolderStack(stack)}
|
||||
onLongPress={() => starredActions(folderPath, name)}
|
||||
delayLongPress={300}
|
||||
style={({ pressed }) => [styles.fileRow, pressed && { backgroundColor: c.surfaceHover }]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Star size={20} color={c.starred} fill={c.starred} />
|
||||
<Text style={[styles.fileName, styles.placeName]} numberOfLines={1}>{name}</Text>
|
||||
<Pressable
|
||||
onPress={() => starredActions(folderPath, name)}
|
||||
hitSlop={8}
|
||||
style={styles.rowMore}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('files.context_menu', 'Actions')}
|
||||
>
|
||||
<MoreVertical size={18} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{recentFiles.length > 0 ? (
|
||||
<Text style={styles.placesTitle} accessibilityRole="header">{t('files.recent', 'Recent')}</Text>
|
||||
) : null}
|
||||
{recentFiles.map((entry) => {
|
||||
const Icon = pickFileIcon(entry.name);
|
||||
return (
|
||||
<Pressable
|
||||
key={entry.id}
|
||||
onPress={() => openRecent(entry.id)}
|
||||
onLongPress={() => recentActions(entry.id, entry.name)}
|
||||
delayLongPress={300}
|
||||
style={({ pressed }) => [styles.fileRow, pressed && { backgroundColor: c.surfaceHover }]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{showIcons ? (
|
||||
<Icon size={20} color={coloredIcons ? fileIconColor(entry.name, c) : c.textMuted} />
|
||||
) : <Clock size={20} color={c.textMuted} />}
|
||||
<Text style={[styles.fileName, styles.placeName]} numberOfLines={1}>{entry.name}</Text>
|
||||
<Pressable
|
||||
onPress={() => recentActions(entry.id, entry.name)}
|
||||
hitSlop={8}
|
||||
style={styles.rowMore}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('files.context_menu', 'Actions')}
|
||||
>
|
||||
<MoreVertical size={18} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
<Text style={styles.placesTitle} accessibilityRole="header">{rootLabel}</Text>
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
// A `/files/<folder>?preview=<name>` 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 = (
|
||||
@@ -1275,6 +1511,7 @@ export default function FilesScreen() {
|
||||
contentContainerStyle={styles.gridContent}
|
||||
onLayout={(e) => setGridWidth(Math.round(e.nativeEvent.layout.width))}
|
||||
renderItem={({ item }) => <View style={{ width: grid.cellWidth }}>{renderGridCell(item)}</View>}
|
||||
ListHeaderComponent={placesHeader}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
|
||||
@@ -1288,6 +1525,7 @@ export default function FilesScreen() {
|
||||
key="list"
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => renderRow(item)}
|
||||
ListHeaderComponent={placesHeader}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
|
||||
@@ -1296,6 +1534,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 +1554,13 @@ export default function FilesScreen() {
|
||||
</View>
|
||||
{previewDocked && (
|
||||
<View style={styles.previewPane}>
|
||||
<FilePreviewModal docked file={previewTarget} onClose={() => setPreviewTarget(null)} />
|
||||
<FilePreviewModal
|
||||
docked
|
||||
file={previewTarget}
|
||||
onClose={() => setPreviewTarget(null)}
|
||||
onPrevious={showPrevious}
|
||||
onNext={showNext}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -1348,7 +1602,7 @@ export default function FilesScreen() {
|
||||
onClose={() => setActionsTarget(null)}
|
||||
onPreview={(r) => {
|
||||
setActionsTarget(null);
|
||||
setPreviewTarget(r);
|
||||
openPreview(r);
|
||||
}}
|
||||
onOpenWith={(r) => {
|
||||
setActionsTarget(null);
|
||||
@@ -1383,6 +1637,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);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FolderPickerSheet
|
||||
@@ -1404,7 +1665,14 @@ export default function FilesScreen() {
|
||||
onChangeDir={(d) => setSetting('filesDefaultSortDir', d)}
|
||||
/>
|
||||
|
||||
{!isSplit && <FilePreviewModal file={previewTarget} onClose={() => setPreviewTarget(null)} />}
|
||||
{!isSplit && (
|
||||
<FilePreviewModal
|
||||
file={previewTarget}
|
||||
onClose={() => setPreviewTarget(null)}
|
||||
onPrevious={showPrevious}
|
||||
onNext={showNext}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ShareSheet
|
||||
node={shareTarget}
|
||||
@@ -1481,6 +1749,7 @@ function PromptModal(props: PromptModalProps) {
|
||||
<View style={styles.dialog}>
|
||||
<Text style={styles.title}>{props.title}</Text>
|
||||
<TextInput
|
||||
accessibilityLabel={props.placeholder ?? props.title}
|
||||
value={props.value}
|
||||
onChangeText={props.onChange}
|
||||
placeholder={props.placeholder}
|
||||
@@ -1527,11 +1796,14 @@ interface ActionsSheetProps {
|
||||
onDelete: (r: FileRow) => 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();
|
||||
@@ -1630,6 +1902,12 @@ function ActionsSheet({
|
||||
<Text style={styles.actionLabel}>{t('files.download', 'Download')}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{starred !== null ? (
|
||||
<Pressable style={styles.action} onPress={() => onToggleStar(target)}>
|
||||
<Star size={18} color={starred ? c.starred : c.text} fill={starred ? c.starred : 'transparent'} />
|
||||
<Text style={styles.actionLabel}>{starred ? t('files.unstar', 'Remove star') : t('files.star', 'Star')}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{canShare ? (
|
||||
<Pressable style={styles.action} onPress={() => onShare(target)}>
|
||||
<Users size={18} color={c.text} />
|
||||
@@ -1968,6 +2246,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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<typeof vi.fn
|
||||
const mockCreateAddressBook = contactsApi.createAddressBook as ReturnType<typeof vi.fn>;
|
||||
const mockDeleteAddressBook = contactsApi.deleteAddressBook as ReturnType<typeof vi.fn>;
|
||||
const mockSetDefaultAddressBook = contactsApi.setDefaultAddressBook as ReturnType<typeof vi.fn>;
|
||||
const mockSetAddressBookShare = contactsApi.setAddressBookShare as ReturnType<typeof vi.fn>;
|
||||
const mockGetContactsInBook = contactsApi.getContactsInBook as ReturnType<typeof vi.fn>;
|
||||
const mockQueryRecent = recentApi.queryRecentRecipients as ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<void>;
|
||||
deleteAddressBook: (id: string) => Promise<void>;
|
||||
setDefaultAddressBook: (id: string) => Promise<void>;
|
||||
/** Give a principal rights on an own address book; null takes them away. */
|
||||
shareAddressBook: (id: string, principalId: string, rights: AddressBookRights | null) => Promise<void>;
|
||||
/** The book new contacts / imports should land in when none is chosen. */
|
||||
getDefaultAddressBookId: () => string | null;
|
||||
|
||||
@@ -924,6 +927,24 @@ export const useContactsStore = create<ContactsState>()(
|
||||
});
|
||||
},
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<string, FilesPlaces>;
|
||||
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<string>) => void;
|
||||
pruneRecentFiles: (account: string, existingIds: Set<string>) => void;
|
||||
}
|
||||
|
||||
export const useFilesPlacesStore = create<FilesPlacesState>()(
|
||||
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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -325,6 +325,10 @@ export interface PersistedSettings {
|
||||
// reader: the quick reply box under a conversation can be turned off
|
||||
// (webmail #1370, same key as the webmail's for export/import).
|
||||
quickReplyEnabled: boolean;
|
||||
// calendar2: events as a light tint of their calendar colour with a
|
||||
// coloured bar at the start, instead of solid blocks (webmail
|
||||
// calendarTintedEvents, same key).
|
||||
calendarTintedEvents: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
@@ -487,6 +491,8 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
pushNotifyInboxOnly: false,
|
||||
// reader:
|
||||
quickReplyEnabled: true,
|
||||
// calendar2:
|
||||
calendarTintedEvents: false,
|
||||
};
|
||||
|
||||
export interface SettingsState extends PersistedSettings {
|
||||
|
||||
Reference in New Issue
Block a user