From 96b163a11796acafaca4d3e5967be895ffb3660d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:36:52 +0100 Subject: [PATCH] feat: share an address book with other people --- src/api/__tests__/contacts.test.ts | 25 ++ src/api/contacts.ts | 30 +- .../__tests__/address-book-share.test.ts | 32 ++ .../__tests__/calendar-i18n.test.ts | 2 + .../calendar/CalendarShareSheet.tsx | 348 +---------------- .../contacts/AddressBookShareSheet.tsx | 39 ++ src/components/settings/ContactsSettings.tsx | 28 +- .../sharing/ShareCollectionSheet.tsx | 353 ++++++++++++++++++ src/stores/__tests__/contacts-store.test.ts | 19 + src/stores/contacts-store.ts | 23 +- 10 files changed, 566 insertions(+), 333 deletions(-) create mode 100644 src/components/__tests__/address-book-share.test.ts create mode 100644 src/components/contacts/AddressBookShareSheet.tsx create mode 100644 src/components/sharing/ShareCollectionSheet.tsx diff --git a/src/api/__tests__/contacts.test.ts b/src/api/__tests__/contacts.test.ts index c812c3c7..bcebb3bc 100644 --- a/src/api/__tests__/contacts.test.ts +++ b/src/api/__tests__/contacts.test.ts @@ -34,6 +34,7 @@ import { updateAddressBook, setDefaultAddressBook, deleteAddressBook, + setAddressBookShare, } from '../contacts'; const mockRequest = jmapClient.request as ReturnType; @@ -72,6 +73,30 @@ describe('contacts operations', () => { }); }); + describe('setAddressBookShare', () => { + const read = { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false }; + + it('patches one principal on the book', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['AddressBook/set', { updated: { ab: null } }, '0']] }); + await setAddressBookShare('ab', 'carol', read, 'acc-team'); + expect(mockRequest.mock.calls[0][0]).toEqual([['AddressBook/set', { + accountId: 'acc-team', + update: { ab: { 'shareWith/carol': read } }, + }, '0']]); + await setAddressBookShare('ab', 'carol', null); + expect(mockRequest.mock.calls[1][0][0][1]).toEqual({ accountId: 'acc-1', update: { ab: { 'shareWith/carol': null } } }); + }); + + it('throws when the server refuses or does not confirm', async () => { + mockRequest.mockResolvedValueOnce({ + methodResponses: [['AddressBook/set', { notUpdated: { ab: { type: 'forbidden', description: 'no' } } }, '0']], + }); + await expect(setAddressBookShare('ab', 'carol', read)).rejects.toThrow('no'); + mockRequest.mockResolvedValueOnce({ methodResponses: [['AddressBook/set', { updated: {} }, '0']] }); + await expect(setAddressBookShare('ab', 'carol', read)).rejects.toThrow('Server did not confirm the share update'); + }); + }); + describe('getAddressBooks', () => { it('should fetch address books with an explicit property list', async () => { const books = [{ id: 'ab-1', name: 'Personal' }]; diff --git a/src/api/contacts.ts b/src/api/contacts.ts index 826a1748..aff203d6 100644 --- a/src/api/contacts.ts +++ b/src/api/contacts.ts @@ -1,7 +1,7 @@ import { jmapClient } from './jmap-client'; import { JMAPSetError } from './jmap-result'; import { CAPABILITIES } from './types'; -import type { ContactCard, ContactMedia, AddressBook } from './types'; +import type { ContactCard, ContactMedia, AddressBook, AddressBookRights } from './types'; import { generateUUID } from '../lib/uuid'; import { contactFromWire, contactToWire } from '../lib/contact-wire'; import { accountSupports } from '../lib/account-capability'; @@ -379,6 +379,34 @@ export async function updateAddressBook( if (err) throw new Error(setErrorMessage(err, 'Failed to update address book')); } +/** + * Give a principal rights on an address book, or take them away with null + * (RFC 9610 `shareWith`, patched per principal like the webmail's + * setAddressBookShare). Throws when the server refuses or doesn't confirm + * the update. + */ +export async function setAddressBookShare( + addressBookId: string, + principalId: string, + rights: AddressBookRights | null, + accountId?: string, +): Promise { + const account = accountId || getContactsAccountId(); + const res = await jmapClient.request( + [['AddressBook/set', { + accountId: account, + update: { [addressBookId]: { [`shareWith/${principalId}`]: rights } }, + }, '0']], + USING, + ); + const result = methodResult<{ updated?: Record | null; notUpdated?: Record }>(res); + const err = result.notUpdated?.[addressBookId]; + if (err) throw setError(err, 'Failed to update address book share'); + if (!result.updated || !(addressBookId in result.updated)) { + throw new Error('Server did not confirm the share update'); + } +} + /** * Mark an address book as the account default. `isDefault` is read-only in * AddressBook/set - the default is changed via the `onSuccessSetIsDefault` diff --git a/src/components/__tests__/address-book-share.test.ts b/src/components/__tests__/address-book-share.test.ts new file mode 100644 index 00000000..ff5906a6 --- /dev/null +++ b/src/components/__tests__/address-book-share.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Address books could not be shared on the phone (file 10 "address book +// sharing", 06 "Address book share / visibility management"). Webmail +// ShareCollectionDialog with kind "addressBook", offered for own books the +// user may share. No RN renderer in the test env: this checks the wiring. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); + +describe('address book sharing', () => { + it('offers the webmail presets, without free/busy', () => { + const sheet = read('components/contacts/AddressBookShareSheet.tsx'); + expect(sheet).toMatch(/read: \{ mayRead: true, mayWrite: false, mayShare: false, mayDelete: false \}/); + expect(sheet).toMatch(/readWrite: \{ mayRead: true, mayWrite: true, mayShare: false, mayDelete: false \}/); + expect(sheet).toMatch(/manager: \{ mayRead: true, mayWrite: true, mayShare: true, mayDelete: true \}/); + expect(sheet).not.toMatch(/freeBusy/); + expect(sheet).toMatch(/addRights=\{ADDRESS_BOOK_PRESETS\.read\}/); + }); + + it('is opened from the address book settings for own books the user may share', () => { + const settings = read('components/settings/ContactsSettings.tsx'); + expect(settings).toMatch(/\{!book\.isShared && book\.myRights\?\.mayShare \? \(\s* setShareTarget\(book\)\}/); + expect(settings).toMatch(/ { + expect(read('components/calendar/CalendarShareSheet.tsx')).toMatch(/ = { 'components/calendar/WeekView.tsx': [], 'components/calendar/CalendarEditSheet.tsx': [], 'components/calendar/CalendarShareSheet.tsx': [], + 'components/sharing/ShareCollectionSheet.tsx': [], + 'components/contacts/AddressBookShareSheet.tsx': [], 'components/calendar/CalendarSidebarDrawer.tsx': [], 'components/calendar/EventDetailSheet.tsx': [], // The meeting link placeholder shows an example URL. diff --git a/src/components/calendar/CalendarShareSheet.tsx b/src/components/calendar/CalendarShareSheet.tsx index f8b8bf89..e7051989 100644 --- a/src/components/calendar/CalendarShareSheet.tsx +++ b/src/components/calendar/CalendarShareSheet.tsx @@ -1,30 +1,12 @@ import React from 'react'; -import { - ActivityIndicator, - Alert, - Modal, - Pressable, - ScrollView, - StyleSheet, - Text, - TextInput, - TouchableWithoutFeedback, - View, -} from 'react-native'; -import { Trash2, UserPlus, Users } from '../icons'; -import { listPrincipals, ownPrincipalId, type PrincipalListing } from '../../api/files'; -import type { Calendar, CalendarRights, Principal } from '../../api/types'; -import { directoryNotice } from '../../lib/principal-directory'; -import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import type { Calendar, CalendarRights } from '../../api/types'; import { useLocaleStore } from '../../stores/locale-store'; -import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; -import { useSheetInsets } from '../../lib/use-layout'; +import { ShareCollectionSheet, type SharePreset } from '../sharing/ShareCollectionSheet'; type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager'; // Same presets the webmail's share-collection dialog offers for calendars. -const CALENDAR_PRESETS: Record = { +export const CALENDAR_PRESETS: Record = { freeBusy: { mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false, @@ -43,323 +25,29 @@ const CALENDAR_PRESETS: Record = { }, }; -// The webmail's sharing presets (same rights, same names). -const PRESET_LABEL_KEYS: Record = { - freeBusy: ['sharing.preset.freeBusy', 'Free/busy only'], - read: ['sharing.preset.read', 'Read only'], - readWrite: ['sharing.preset.readWrite', 'Read & write'], - manager: ['sharing.preset.manager', 'Manager'], -}; - -const PRESET_ORDER: RolePreset[] = ['freeBusy', 'read', 'readWrite', 'manager']; - -function detectPreset(rights: CalendarRights): RolePreset | 'custom' { - for (const preset of PRESET_ORDER) { - const expected = CALENDAR_PRESETS[preset]; - const keys = Object.keys(expected) as (keyof CalendarRights)[]; - if (keys.every((k) => !!expected[k] === !!rights[k])) return preset; - } - return 'custom'; -} - interface CalendarShareSheetProps { calendar: Calendar | null; onShare: (calendarId: string, principalId: string, rights: CalendarRights | null) => Promise; onClose: () => void; } -// JMAP sharing for an owned calendar: pick a principal, choose a role. The -// principal list comes from the same Principal/query the Files share sheet -// uses. +// JMAP sharing for an owned calendar: pick a principal, choose a role. export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShareSheetProps) { - // Centred on a wide window, clear of side insets in landscape. - const sheetInsets = useSheetInsets(); - const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); - - const [principals, setPrincipals] = React.useState([]); - const [directory, setDirectory] = React.useState>({ status: 'ok' }); - const [loading, setLoading] = React.useState(false); - const [shares, setShares] = React.useState>({}); - const [search, setSearch] = React.useState(''); - const [savingId, setSavingId] = React.useState(null); - - React.useEffect(() => { - if (!calendar) return undefined; - let cancelled = false; - setShares(calendar.shareWith ?? {}); - setSearch(''); - setLoading(true); - void listPrincipals().then(({ principals: list, ...listing }) => { - if (cancelled) return; - setPrincipals(list); - setDirectory(listing); - setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [calendar]); - - const principalsById = React.useMemo(() => { - const map = new Map(); - for (const p of principals) map.set(p.id, p); - return map; - }, [principals]); - - const sharedEntries = React.useMemo( - () => Object.entries(shares).filter(([, rights]) => rights != null), - [shares], - ); - - const candidates = React.useMemo(() => { - // The sheet is mounted with the Calendar tab, before a session may exist - // (an offline cold start): nobody to list yet, and no own id to read. - if (principals.length === 0) return []; - const self = ownPrincipalId(); - const q = search.trim().toLowerCase(); - return principals - .filter((p) => p.id !== self && shares[p.id] == null) - .filter((p) => - !q || - p.name?.toLowerCase().includes(q) || - p.email?.toLowerCase().includes(q) || - p.description?.toLowerCase().includes(q)) - .slice(0, 25); - }, [principals, shares, search]); - - const applyShare = async (principalId: string, rights: CalendarRights | null) => { - if (!calendar || savingId) return; - setSavingId(principalId); - try { - await onShare(calendar.id, principalId, rights); - setShares((prev) => { - const next = { ...prev }; - if (rights == null) delete next[principalId]; - else next[principalId] = rights; - return next; - }); - } catch (e) { - Alert.alert( - t('calendar.share.error', 'Failed to update sharing'), - e instanceof Error ? e.message : String(e), - ); - } finally { - setSavingId(null); - } - }; - - if (!calendar) return null; - - // The list below is partial or empty for a reason the user can't see - // otherwise; "Sharing is not available on this server." would be wrong. - const notice = loading ? null : directoryNotice(directory); - const noticeText = - notice === 'failed' ? t('sharing.directory_failed', "Couldn't load the list of users and groups.") - : notice === 'restricted' - ? t( - 'sharing.directory_restricted', - "Your server doesn't allow browsing its user directory, so only your groups and people who have shared with you are listed. An administrator can turn on directory queries in Stalwart's sharing settings.", - ) - : notice === 'not_browsable' - ? t('sharing.directory_not_browsable', "Your server doesn't let you browse its user directory.") - : null; - - const renderPrincipalLabel = (principalId: string) => { - const p = principalsById.get(principalId); - // Stalwart 1.0 with directory queries off hides every principal the user - // has no relationship with, so a sharee may have no name. - const unresolved = !p && !loading; - return ( - - - {p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)} - - {p?.email || unresolved ? ( - {p?.email || principalId} - ) : null} - - ); - }; - + // The webmail's sharing presets (same rights, same names). + const presets = React.useMemo[]>(() => [ + { key: 'freeBusy', label: t('sharing.preset.freeBusy', 'Free/busy only'), rights: CALENDAR_PRESETS.freeBusy }, + { key: 'read', label: t('sharing.preset.read', 'Read only'), rights: CALENDAR_PRESETS.read }, + { key: 'readWrite', label: t('sharing.preset.readWrite', 'Read & write'), rights: CALENDAR_PRESETS.readWrite }, + { key: 'manager', label: t('sharing.preset.manager', 'Manager'), rights: CALENDAR_PRESETS.manager }, + ], [t]); return ( - - - - - - - - - {t('sharing.title', 'Share "{name}"', { name: calendar.name })} - - - - - {sharedEntries.length > 0 ? ( - - {t('calendar.share.shared_with', 'Shared with')} - {sharedEntries.map(([principalId, rights]) => { - const preset = detectPreset(rights); - const busy = savingId === principalId; - return ( - - {renderPrincipalLabel(principalId)} - - {PRESET_ORDER.map((p) => ( - void applyShare(principalId, CALENDAR_PRESETS[p])} - disabled={busy} - style={[styles.chip, preset === p && styles.chipActive]} - accessibilityRole="button" - accessibilityState={{ selected: preset === p, disabled: busy }} - > - - {t(PRESET_LABEL_KEYS[p][0], PRESET_LABEL_KEYS[p][1])} - - - ))} - {preset === 'custom' ? ( - {t('sharing.preset.custom', 'Custom')} - ) : null} - void applyShare(principalId, null)} - disabled={busy} - hitSlop={8} - style={styles.removeBtn} - accessibilityRole="button" - accessibilityLabel={t('sharing.remove', 'Remove access')} - > - {busy ? ( - - ) : ( - - )} - - - - ); - })} - - ) : null} - - - {t('calendar.share.add_people', 'Add people')} - - {noticeText ? ( - {noticeText} - ) : null} - {loading ? ( - - ) : candidates.length === 0 ? ( - notice && !search.trim() ? null : ( - - {principals.length === 0 && !notice - ? t('calendar.share.no_principals', 'Sharing is not available on this server.') - : t('calendar.share.no_matches', 'No matches')} - - ) - ) : ( - candidates.map((p) => ( - void applyShare(p.id, CALENDAR_PRESETS.read)} - disabled={!!savingId} - style={({ pressed }) => [styles.candidateRow, pressed && styles.rowPressed]} - > - {renderPrincipalLabel(p.id)} - {savingId === p.id ? ( - - ) : ( - - )} - - )) - )} - - - - - {t('common.done', 'Done')} - - - - - - + ); } - -function makeStyles(c: ThemePalette) { - return StyleSheet.create({ - backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, - sheet: { - backgroundColor: c.background, - borderTopLeftRadius: radius.xl, - borderTopRightRadius: radius.xl, - padding: spacing.lg, - paddingBottom: spacing.xxl, - maxHeight: '85%', - }, - titleRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.md }, - title: { ...typography.h3, color: c.text, flex: 1 }, - scroll: { flexGrow: 0 }, - section: { marginBottom: spacing.lg, gap: spacing.sm }, - sectionLabel: { ...typography.captionMedium, color: c.textSecondary }, - shareRow: { - gap: spacing.xs, - paddingVertical: spacing.sm, - borderBottomWidth: 1, - borderBottomColor: c.borderLight, - }, - principalInfo: { flex: 1, minWidth: 0 }, - principalName: { ...typography.body, color: c.text }, - principalEmail: { ...typography.caption, color: c.textMuted }, - roleChips: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: spacing.xs }, - chip: { - paddingHorizontal: spacing.sm, - paddingVertical: 4, - borderRadius: radius.full, - borderWidth: 1, - borderColor: c.border, - backgroundColor: c.surface, - }, - chipActive: { backgroundColor: c.primary, borderColor: c.primary }, - chipText: { ...typography.caption, color: c.text }, - chipTextActive: { color: c.primaryForeground }, - customLabel: { ...typography.caption, color: c.textMuted }, - removeBtn: { marginLeft: 'auto', padding: 4 }, - input: { - minHeight: 40, - borderWidth: 1, - borderColor: c.border, - borderRadius: radius.sm, - paddingHorizontal: spacing.md, - color: c.text, - backgroundColor: c.surface, - ...typography.body, - }, - empty: { ...typography.caption, color: c.textMuted, paddingVertical: spacing.sm }, - candidateRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - paddingVertical: spacing.sm, - borderBottomWidth: 1, - borderBottomColor: c.borderLight, - }, - rowPressed: { backgroundColor: c.surfaceHover }, - doneBtn: { alignSelf: 'flex-end', paddingVertical: spacing.sm, paddingHorizontal: spacing.md }, - doneText: { ...typography.bodyMedium, color: c.primary }, - }); -} diff --git a/src/components/contacts/AddressBookShareSheet.tsx b/src/components/contacts/AddressBookShareSheet.tsx new file mode 100644 index 00000000..e35a9aff --- /dev/null +++ b/src/components/contacts/AddressBookShareSheet.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import type { AddressBook, AddressBookRights } from '../../api/types'; +import { useLocaleStore } from '../../stores/locale-store'; +import { ShareCollectionSheet, type SharePreset } from '../sharing/ShareCollectionSheet'; + +// The webmail's address book presets (lib/jmap/share-rights ADDRESS_BOOK_PRESETS): +// no free/busy, which only calendars have. +export const ADDRESS_BOOK_PRESETS = { + read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false }, + readWrite: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: false }, + manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, +} satisfies Record; + +interface AddressBookShareSheetProps { + /** Null hides the sheet. */ + book: AddressBook | null; + onShare: (bookId: string, principalId: string, rights: AddressBookRights | null) => Promise; + onClose: () => void; +} + +// JMAP sharing for an own address book (webmail ShareCollectionDialog, kind +// "addressBook"): pick a principal, choose a role. +export function AddressBookShareSheet({ book, onShare, onClose }: AddressBookShareSheetProps) { + const t = useLocaleStore((s) => s.t); + const presets = React.useMemo[]>(() => [ + { key: 'read', label: t('sharing.preset.read', 'Read only'), rights: ADDRESS_BOOK_PRESETS.read }, + { key: 'readWrite', label: t('sharing.preset.readWrite', 'Read & write'), rights: ADDRESS_BOOK_PRESETS.readWrite }, + { key: 'manager', label: t('sharing.preset.manager', 'Manager'), rights: ADDRESS_BOOK_PRESETS.manager }, + ], [t]); + return ( + + ); +} diff --git a/src/components/settings/ContactsSettings.tsx b/src/components/settings/ContactsSettings.tsx index 854c6f72..555887ae 100644 --- a/src/components/settings/ContactsSettings.tsx +++ b/src/components/settings/ContactsSettings.tsx @@ -3,12 +3,13 @@ import { Alert, Share, Text, View, StyleSheet, Pressable, TextInput } from 'reac import * as FileSystem from 'expo-file-system/legacy'; import * as Sharing from 'expo-sharing'; import { - Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser, Star, + Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser, Star, Users, } from '../icons'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import Button from '../Button'; import Dialog from '../Dialog'; import { ContactImportSheet, AddressBookPickerSheet } from '../contacts'; +import { AddressBookShareSheet } from '../contacts/AddressBookShareSheet'; import { DeviceSyncSection } from './device-sync/DeviceSyncSection'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; @@ -20,6 +21,7 @@ import { deviceSyncAvailable } from '../../device-sync/app/available'; import { CONTACTS_AUTHORITY } from '../../device-sync/types'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import type { AddressBook } from '../../api/types'; export function ContactsSettings() { const c = useColors(); @@ -45,6 +47,7 @@ export function ContactsSettings() { const deleteAddressBook = useContactsStore((s) => s.deleteAddressBook); const setDefaultAddressBook = useContactsStore((s) => s.setDefaultAddressBook); const getDefaultAddressBookId = useContactsStore((s) => s.getDefaultAddressBookId); + const shareAddressBook = useContactsStore((s) => s.shareAddressBook); const [exporting, setExporting] = React.useState(false); const [importOpen, setImportOpen] = React.useState(false); @@ -57,6 +60,8 @@ export function ContactsSettings() { const [newName, setNewName] = React.useState(''); const [busy, setBusy] = React.useState(false); const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string; count: number } | null>(null); + // The book whose sharing sheet is open, as it was when opened. + const [shareTarget, setShareTarget] = React.useState(null); // Android with the native module only (#34). const deviceSync = React.useMemo(() => deviceSyncAvailable(), []); @@ -263,6 +268,21 @@ export function ContactsSettings() { {book.isDefault ? ` ยท ${t('contacts.address_books.default', 'Default')}` : ''} + {/* Own books the user may share (webmail address-book settings). */} + {!book.isShared && book.myRights?.mayShare ? ( + setShareTarget(book)} + hitSlop={6} + style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel={t('contacts.address_books.share', 'Share address book')} + > + 0 ? c.primary : c.textSecondary} + /> + + ) : null} {!book.isShared && ( { if (!book.isDefault) void handleSetDefault(book.id); }} @@ -392,6 +412,12 @@ export function ContactsSettings() { onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }} /> + setShareTarget(null)} + /> + { + key: string; + label: string; + rights: R; +} + +/** The preset whose rights `rights` match, or 'custom'. */ +export function detectSharePreset(rights: R, presets: SharePreset[]): string { + for (const preset of presets) { + const keys = Object.keys(preset.rights) as (keyof R)[]; + if (keys.every((k) => !!preset.rights[k] === !!rights[k])) return preset.key; + } + return 'custom'; +} + +/** The collection being shared: a calendar, an address book. */ +export interface ShareableCollection { + id: string; + name: string; + shareWith?: Record | null; +} + +interface ShareCollectionSheetProps { + /** Null hides the sheet. */ + collection: ShareableCollection | null; + presets: SharePreset[]; + /** What a new share gets (the webmail: read). */ + addRights: R; + onShare: (collectionId: string, principalId: string, rights: R | null) => Promise; + onClose: () => void; +} + +// JMAP sharing for an owned collection: pick a principal, choose a role +// (webmail ShareCollectionDialog). The principal list comes from the same +// Principal/query the Files share sheet uses. +export function ShareCollectionSheet({ + collection, presets, addRights, onShare, onClose, +}: ShareCollectionSheetProps) { + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + + const [principals, setPrincipals] = React.useState([]); + const [directory, setDirectory] = React.useState>({ status: 'ok' }); + const [loading, setLoading] = React.useState(false); + const [shares, setShares] = React.useState>({}); + const [search, setSearch] = React.useState(''); + const [savingId, setSavingId] = React.useState(null); + + React.useEffect(() => { + if (!collection) return undefined; + let cancelled = false; + setShares(collection.shareWith ?? {}); + setSearch(''); + setLoading(true); + void listPrincipals().then(({ principals: list, ...listing }) => { + if (cancelled) return; + setPrincipals(list); + setDirectory(listing); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [collection]); + + const principalsById = React.useMemo(() => { + const map = new Map(); + for (const p of principals) map.set(p.id, p); + return map; + }, [principals]); + + const sharedEntries = React.useMemo( + () => Object.entries(shares).filter(([, rights]) => rights != null), + [shares], + ); + + const candidates = React.useMemo(() => { + // The sheet is mounted with its tab, before a session may exist + // (an offline cold start): nobody to list yet, and no own id to read. + if (principals.length === 0) return []; + const self = ownPrincipalId(); + const q = search.trim().toLowerCase(); + return principals + .filter((p) => p.id !== self && shares[p.id] == null) + .filter((p) => + !q || + p.name?.toLowerCase().includes(q) || + p.email?.toLowerCase().includes(q) || + p.description?.toLowerCase().includes(q)) + .slice(0, 25); + }, [principals, shares, search]); + + const applyShare = async (principalId: string, rights: R | null) => { + if (!collection || savingId) return; + setSavingId(principalId); + try { + await onShare(collection.id, principalId, rights); + setShares((prev) => { + const next = { ...prev }; + if (rights == null) delete next[principalId]; + else next[principalId] = rights; + return next; + }); + } catch (e) { + Alert.alert( + t('calendar.share.error', 'Failed to update sharing'), + e instanceof Error ? e.message : String(e), + ); + } finally { + setSavingId(null); + } + }; + + if (!collection) return null; + + // The list below is partial or empty for a reason the user can't see + // otherwise; "Sharing is not available on this server." would be wrong. + const notice = loading ? null : directoryNotice(directory); + const noticeText = + notice === 'failed' ? t('sharing.directory_failed', "Couldn't load the list of users and groups.") + : notice === 'restricted' + ? t( + 'sharing.directory_restricted', + "Your server doesn't allow browsing its user directory, so only your groups and people who have shared with you are listed. An administrator can turn on directory queries in Stalwart's sharing settings.", + ) + : notice === 'not_browsable' + ? t('sharing.directory_not_browsable', "Your server doesn't let you browse its user directory.") + : null; + + const renderPrincipalLabel = (principalId: string) => { + const p = principalsById.get(principalId); + // Stalwart 1.0 with directory queries off hides every principal the user + // has no relationship with, so a sharee may have no name. + const unresolved = !p && !loading; + return ( + + + {p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)} + + {p?.email || unresolved ? ( + {p?.email || principalId} + ) : null} + + ); + }; + + return ( + + + + + + + + + {t('sharing.title', 'Share "{name}"', { name: collection.name })} + + + + + {sharedEntries.length > 0 ? ( + + {t('calendar.share.shared_with', 'Shared with')} + {sharedEntries.map(([principalId, rights]) => { + const preset = detectSharePreset(rights, presets); + const busy = savingId === principalId; + return ( + + {renderPrincipalLabel(principalId)} + + {presets.map((p) => ( + void applyShare(principalId, p.rights)} + disabled={busy} + style={[styles.chip, preset === p.key && styles.chipActive]} + accessibilityRole="button" + accessibilityState={{ selected: preset === p.key, disabled: busy }} + > + + {p.label} + + + ))} + {preset === 'custom' ? ( + {t('sharing.preset.custom', 'Custom')} + ) : null} + void applyShare(principalId, null)} + disabled={busy} + hitSlop={8} + style={styles.removeBtn} + accessibilityRole="button" + accessibilityLabel={t('sharing.remove', 'Remove access')} + > + {busy ? ( + + ) : ( + + )} + + + + ); + })} + + ) : null} + + + {t('calendar.share.add_people', 'Add people')} + + {noticeText ? ( + {noticeText} + ) : null} + {loading ? ( + + ) : candidates.length === 0 ? ( + notice && !search.trim() ? null : ( + + {principals.length === 0 && !notice + ? t('calendar.share.no_principals', 'Sharing is not available on this server.') + : t('calendar.share.no_matches', 'No matches')} + + ) + ) : ( + candidates.map((p) => ( + void applyShare(p.id, addRights)} + disabled={!!savingId} + style={({ pressed }) => [styles.candidateRow, pressed && styles.rowPressed]} + > + {renderPrincipalLabel(p.id)} + {savingId === p.id ? ( + + ) : ( + + )} + + )) + )} + + + + + {t('common.done', 'Done')} + + + + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, + sheet: { + backgroundColor: c.background, + borderTopLeftRadius: radius.xl, + borderTopRightRadius: radius.xl, + padding: spacing.lg, + paddingBottom: spacing.xxl, + maxHeight: '85%', + }, + titleRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.md }, + title: { ...typography.h3, color: c.text, flex: 1 }, + scroll: { flexGrow: 0 }, + section: { marginBottom: spacing.lg, gap: spacing.sm }, + sectionLabel: { ...typography.captionMedium, color: c.textSecondary }, + shareRow: { + gap: spacing.xs, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.borderLight, + }, + principalInfo: { flex: 1, minWidth: 0 }, + principalName: { ...typography.body, color: c.text }, + principalEmail: { ...typography.caption, color: c.textMuted }, + roleChips: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: spacing.xs }, + chip: { + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: radius.full, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + chipActive: { backgroundColor: c.primary, borderColor: c.primary }, + chipText: { ...typography.caption, color: c.text }, + chipTextActive: { color: c.primaryForeground }, + customLabel: { ...typography.caption, color: c.textMuted }, + removeBtn: { marginLeft: 'auto', padding: 4 }, + input: { + minHeight: 40, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + paddingHorizontal: spacing.md, + color: c.text, + backgroundColor: c.surface, + ...typography.body, + }, + empty: { ...typography.caption, color: c.textMuted, paddingVertical: spacing.sm }, + candidateRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.borderLight, + }, + rowPressed: { backgroundColor: c.surfaceHover }, + doneBtn: { alignSelf: 'flex-end', paddingVertical: spacing.sm, paddingHorizontal: spacing.md }, + doneText: { ...typography.bodyMedium, color: c.primary }, + }); +} diff --git a/src/stores/__tests__/contacts-store.test.ts b/src/stores/__tests__/contacts-store.test.ts index b922bd35..2794445c 100644 --- a/src/stores/__tests__/contacts-store.test.ts +++ b/src/stores/__tests__/contacts-store.test.ts @@ -13,6 +13,7 @@ vi.mock('../../api/contacts', () => ({ updateAddressBook: vi.fn(), deleteAddressBook: vi.fn(), setDefaultAddressBook: vi.fn(), + setAddressBookShare: vi.fn(), getContactsInBook: vi.fn(), getContactMedia: vi.fn(), getContactsAccountId: () => 'acc-1', @@ -87,6 +88,7 @@ const mockDeleteContacts = contactsApi.deleteContacts as ReturnType; const mockDeleteAddressBook = contactsApi.deleteAddressBook as ReturnType; const mockSetDefaultAddressBook = contactsApi.setDefaultAddressBook as ReturnType; +const mockSetAddressBookShare = contactsApi.setAddressBookShare as ReturnType; const mockGetContactsInBook = contactsApi.getContactsInBook as ReturnType; const mockQueryRecent = recentApi.queryRecentRecipients as ReturnType; @@ -519,6 +521,23 @@ describe('contacts-store', () => { expect(useContactsStore.getState().addressBooks.map((b) => !!b.isDefault)).toEqual([false, true]); }); + it('shareAddressBook writes to the account the book is in and keeps the shares', async () => { + const read = { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false }; + useContactsStore.setState({ + addressBooks: [ + { id: 'ab-1', name: 'P', shareWith: { bob: read } }, + { id: 'acc-team:ab', originalId: 'ab', accountId: 'acc-team', name: 'Team', isShared: true }, + ], + }); + mockSetAddressBookShare.mockResolvedValue(undefined); + await useContactsStore.getState().shareAddressBook('ab-1', 'carol', read); + expect(mockSetAddressBookShare).toHaveBeenCalledWith('ab-1', 'carol', read, 'acc-1'); + await useContactsStore.getState().shareAddressBook('ab-1', 'bob', null); + expect(useContactsStore.getState().addressBooks[0].shareWith).toEqual({ carol: read }); + await useContactsStore.getState().shareAddressBook('acc-team:ab', 'carol', read); + expect(mockSetAddressBookShare).toHaveBeenLastCalledWith('ab', 'carol', read, 'acc-team'); + }); + it('getDefaultAddressBookId prefers the default own book', () => { useContactsStore.setState({ addressBooks: [ diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts index 5992fcbf..a568451c 100644 --- a/src/stores/contacts-store.ts +++ b/src/stores/contacts-store.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { createPersistStorage, memoizeSlice } from './persist-storage'; -import type { ContactCard, AddressBook, StateChange, EmailAddress } from '../api/types'; +import type { ContactCard, AddressBook, AddressBookRights, StateChange, EmailAddress } from '../api/types'; import { getAddressBooks as fetchPrimaryAddressBooks, getAllAddressBooks as fetchAllAddressBooks, @@ -16,6 +16,7 @@ import { updateAddressBook as apiUpdateAddressBook, deleteAddressBook as apiDeleteAddressBook, setDefaultAddressBook as apiSetDefaultAddressBook, + setAddressBookShare as apiSetAddressBookShare, getContactsInBook, getContactsAccountId, getContactCapableAccountIds, @@ -152,6 +153,8 @@ export interface ContactsState { renameAddressBook: (id: string, name: string) => Promise; deleteAddressBook: (id: string) => Promise; setDefaultAddressBook: (id: string) => Promise; + /** Give a principal rights on an own address book; null takes them away. */ + shareAddressBook: (id: string, principalId: string, rights: AddressBookRights | null) => Promise; /** The book new contacts / imports should land in when none is chosen. */ getDefaultAddressBookId: () => string | null; @@ -924,6 +927,24 @@ export const useContactsStore = create()( }); }, + shareAddressBook: async (id, principalId, rights) => { + const { check } = generationGuard(); + const { originalId, accountId } = bookTarget(id); + // The write goes to the account the book was listed in, even if + // the login changes while it is on its way. + await apiSetAddressBookShare(originalId, principalId, rights, accountId ?? getContactsAccountId()); + check(); + set({ + addressBooks: get().addressBooks.map((b) => { + if (b.id !== id) return b; + const next = { ...(b.shareWith ?? {}) }; + if (rights === null) delete next[principalId]; + else next[principalId] = rights; + return { ...b, shareWith: next }; + }), + }); + }, + getDefaultAddressBookId: () => { const books = get().addressBooks; const own = books.filter((b) => !b.isShared && b.myRights?.mayWrite !== false);