feat: share an address book with other people

This commit is contained in:
Linus Rath
2026-10-31 05:36:52 +01:00
parent d684b00e9b
commit 96b163a117
10 changed files with 566 additions and 333 deletions
+25
View File
@@ -34,6 +34,7 @@ import {
updateAddressBook, updateAddressBook,
setDefaultAddressBook, setDefaultAddressBook,
deleteAddressBook, deleteAddressBook,
setAddressBookShare,
} from '../contacts'; } from '../contacts';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>; const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
@@ -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', () => { describe('getAddressBooks', () => {
it('should fetch address books with an explicit property list', async () => { it('should fetch address books with an explicit property list', async () => {
const books = [{ id: 'ab-1', name: 'Personal' }]; const books = [{ id: 'ab-1', name: 'Personal' }];
+29 -1
View File
@@ -1,7 +1,7 @@
import { jmapClient } from './jmap-client'; import { jmapClient } from './jmap-client';
import { JMAPSetError } from './jmap-result'; import { JMAPSetError } from './jmap-result';
import { CAPABILITIES } from './types'; 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 { generateUUID } from '../lib/uuid';
import { contactFromWire, contactToWire } from '../lib/contact-wire'; import { contactFromWire, contactToWire } from '../lib/contact-wire';
import { accountSupports } from '../lib/account-capability'; 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')); 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 * Mark an address book as the account default. `isDefault` is read-only in
* AddressBook/set - the default is changed via the `onSuccessSetIsDefault` * AddressBook/set - the default is changed via the `onSuccessSetIsDefault`
@@ -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/);
});
});
@@ -19,6 +19,8 @@ const TRANSLATED_FILES: Record<string, string[]> = {
'components/calendar/WeekView.tsx': [], 'components/calendar/WeekView.tsx': [],
'components/calendar/CalendarEditSheet.tsx': [], 'components/calendar/CalendarEditSheet.tsx': [],
'components/calendar/CalendarShareSheet.tsx': [], 'components/calendar/CalendarShareSheet.tsx': [],
'components/sharing/ShareCollectionSheet.tsx': [],
'components/contacts/AddressBookShareSheet.tsx': [],
'components/calendar/CalendarSidebarDrawer.tsx': [], 'components/calendar/CalendarSidebarDrawer.tsx': [],
'components/calendar/EventDetailSheet.tsx': [], 'components/calendar/EventDetailSheet.tsx': [],
// The meeting link placeholder shows an example URL. // The meeting link placeholder shows an example URL.
+18 -330
View File
@@ -1,30 +1,12 @@
import React from 'react'; import React from 'react';
import { import type { Calendar, CalendarRights } from '../../api/types';
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 { useLocaleStore } from '../../stores/locale-store'; import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { ShareCollectionSheet, type SharePreset } from '../sharing/ShareCollectionSheet';
import { useSheetInsets } from '../../lib/use-layout';
type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager'; type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager';
// Same presets the webmail's share-collection dialog offers for calendars. // 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: { freeBusy: {
mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false, mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: 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 { interface CalendarShareSheetProps {
calendar: Calendar | null; calendar: Calendar | null;
onShare: (calendarId: string, principalId: string, rights: CalendarRights | null) => Promise<void>; onShare: (calendarId: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
onClose: () => void; onClose: () => void;
} }
// JMAP sharing for an owned calendar: pick a principal, choose a role. The // JMAP sharing for an owned calendar: pick a principal, choose a role.
// principal list comes from the same Principal/query the Files share sheet
// uses.
export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShareSheetProps) { 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 t = useLocaleStore((s) => s.t);
// The webmail's sharing presets (same rights, same names).
const [principals, setPrincipals] = React.useState<Principal[]>([]); const presets = React.useMemo<SharePreset<CalendarRights>[]>(() => [
const [directory, setDirectory] = React.useState<Omit<PrincipalListing, 'principals'>>({ status: 'ok' }); { key: 'freeBusy', label: t('sharing.preset.freeBusy', 'Free/busy only'), rights: CALENDAR_PRESETS.freeBusy },
const [loading, setLoading] = React.useState(false); { key: 'read', label: t('sharing.preset.read', 'Read only'), rights: CALENDAR_PRESETS.read },
const [shares, setShares] = React.useState<Record<string, CalendarRights>>({}); { key: 'readWrite', label: t('sharing.preset.readWrite', 'Read & write'), rights: CALENDAR_PRESETS.readWrite },
const [search, setSearch] = React.useState(''); { key: 'manager', label: t('sharing.preset.manager', 'Manager'), rights: CALENDAR_PRESETS.manager },
const [savingId, setSavingId] = React.useState<string | null>(null); ], [t]);
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>
);
};
return ( return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}> <ShareCollectionSheet
<TouchableWithoutFeedback accessible={false} onPress={onClose}> collection={calendar}
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}> presets={presets}
<TouchableWithoutFeedback accessible={false}> addRights={CALENDAR_PRESETS.read}
<View style={styles.sheet}> onShare={onShare}
<View style={styles.titleRow}> onClose={onClose}
<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>
); );
} }
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,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}
/>
);
}
+27 -1
View File
@@ -3,12 +3,13 @@ import { Alert, Share, Text, View, StyleSheet, Pressable, TextInput } from 'reac
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing'; import * as Sharing from 'expo-sharing';
import { import {
Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser, Star, Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser, Star, Users,
} from '../icons'; } from '../icons';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button'; import Button from '../Button';
import Dialog from '../Dialog'; import Dialog from '../Dialog';
import { ContactImportSheet, AddressBookPickerSheet } from '../contacts'; import { ContactImportSheet, AddressBookPickerSheet } from '../contacts';
import { AddressBookShareSheet } from '../contacts/AddressBookShareSheet';
import { DeviceSyncSection } from './device-sync/DeviceSyncSection'; import { DeviceSyncSection } from './device-sync/DeviceSyncSection';
import { useSettingsStore } from '../../stores/settings-store'; import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-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 { CONTACTS_AUTHORITY } from '../../device-sync/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import type { AddressBook } from '../../api/types';
export function ContactsSettings() { export function ContactsSettings() {
const c = useColors(); const c = useColors();
@@ -45,6 +47,7 @@ export function ContactsSettings() {
const deleteAddressBook = useContactsStore((s) => s.deleteAddressBook); const deleteAddressBook = useContactsStore((s) => s.deleteAddressBook);
const setDefaultAddressBook = useContactsStore((s) => s.setDefaultAddressBook); const setDefaultAddressBook = useContactsStore((s) => s.setDefaultAddressBook);
const getDefaultAddressBookId = useContactsStore((s) => s.getDefaultAddressBookId); const getDefaultAddressBookId = useContactsStore((s) => s.getDefaultAddressBookId);
const shareAddressBook = useContactsStore((s) => s.shareAddressBook);
const [exporting, setExporting] = React.useState(false); const [exporting, setExporting] = React.useState(false);
const [importOpen, setImportOpen] = React.useState(false); const [importOpen, setImportOpen] = React.useState(false);
@@ -57,6 +60,8 @@ export function ContactsSettings() {
const [newName, setNewName] = React.useState(''); const [newName, setNewName] = React.useState('');
const [busy, setBusy] = React.useState(false); const [busy, setBusy] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string; count: number } | null>(null); 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). // Android with the native module only (#34).
const deviceSync = React.useMemo(() => deviceSyncAvailable(), []); const deviceSync = React.useMemo(() => deviceSyncAvailable(), []);
@@ -263,6 +268,21 @@ export function ContactsSettings() {
{book.isDefault ? ` · ${t('contacts.address_books.default', 'Default')}` : ''} {book.isDefault ? ` · ${t('contacts.address_books.default', 'Default')}` : ''}
</Text> </Text>
</View> </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 && ( {!book.isShared && (
<Pressable <Pressable
onPress={() => { if (!book.isDefault) void handleSetDefault(book.id); }} onPress={() => { if (!book.isDefault) void handleSetDefault(book.id); }}
@@ -392,6 +412,12 @@ export function ContactsSettings() {
onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }} onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }}
/> />
<AddressBookShareSheet
book={shareTarget}
onShare={shareAddressBook}
onClose={() => setShareTarget(null)}
/>
<Dialog <Dialog
visible={deleteTarget !== null} visible={deleteTarget !== null}
title={t('contacts.address_books.delete', 'Delete address book')} title={t('contacts.address_books.delete', 'Delete address book')}
@@ -0,0 +1,353 @@
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
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 },
});
}
@@ -13,6 +13,7 @@ vi.mock('../../api/contacts', () => ({
updateAddressBook: vi.fn(), updateAddressBook: vi.fn(),
deleteAddressBook: vi.fn(), deleteAddressBook: vi.fn(),
setDefaultAddressBook: vi.fn(), setDefaultAddressBook: vi.fn(),
setAddressBookShare: vi.fn(),
getContactsInBook: vi.fn(), getContactsInBook: vi.fn(),
getContactMedia: vi.fn(), getContactMedia: vi.fn(),
getContactsAccountId: () => 'acc-1', 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 mockCreateAddressBook = contactsApi.createAddressBook as ReturnType<typeof vi.fn>;
const mockDeleteAddressBook = contactsApi.deleteAddressBook as ReturnType<typeof vi.fn>; const mockDeleteAddressBook = contactsApi.deleteAddressBook as ReturnType<typeof vi.fn>;
const mockSetDefaultAddressBook = contactsApi.setDefaultAddressBook 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 mockGetContactsInBook = contactsApi.getContactsInBook as ReturnType<typeof vi.fn>;
const mockQueryRecent = recentApi.queryRecentRecipients 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]); 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', () => { it('getDefaultAddressBookId prefers the default own book', () => {
useContactsStore.setState({ useContactsStore.setState({
addressBooks: [ addressBooks: [
+22 -1
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { createPersistStorage, memoizeSlice } from './persist-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 { import {
getAddressBooks as fetchPrimaryAddressBooks, getAddressBooks as fetchPrimaryAddressBooks,
getAllAddressBooks as fetchAllAddressBooks, getAllAddressBooks as fetchAllAddressBooks,
@@ -16,6 +16,7 @@ import {
updateAddressBook as apiUpdateAddressBook, updateAddressBook as apiUpdateAddressBook,
deleteAddressBook as apiDeleteAddressBook, deleteAddressBook as apiDeleteAddressBook,
setDefaultAddressBook as apiSetDefaultAddressBook, setDefaultAddressBook as apiSetDefaultAddressBook,
setAddressBookShare as apiSetAddressBookShare,
getContactsInBook, getContactsInBook,
getContactsAccountId, getContactsAccountId,
getContactCapableAccountIds, getContactCapableAccountIds,
@@ -152,6 +153,8 @@ export interface ContactsState {
renameAddressBook: (id: string, name: string) => Promise<void>; renameAddressBook: (id: string, name: string) => Promise<void>;
deleteAddressBook: (id: string) => Promise<void>; deleteAddressBook: (id: string) => Promise<void>;
setDefaultAddressBook: (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. */ /** The book new contacts / imports should land in when none is chosen. */
getDefaultAddressBookId: () => string | null; 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: () => { getDefaultAddressBookId: () => {
const books = get().addressBooks; const books = get().addressBooks;
const own = books.filter((b) => !b.isShared && b.myRights?.mayWrite !== false); const own = books.filter((b) => !b.isShared && b.myRights?.mayWrite !== false);