mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: share a mail folder with other people from its menu
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Live checks of folder sharing (the folder menu's "Share…") against a real
|
||||
* Stalwart: the first login makes a folder, shares it with the second one,
|
||||
* changes the role, and takes the access away again.
|
||||
*
|
||||
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP`,
|
||||
* `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` are set (`BULWARK_LIVE_PASS2`
|
||||
* defaults to the first password).
|
||||
*
|
||||
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
|
||||
* BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \
|
||||
* BULWARK_LIVE_USER2=e2e-tablet-land@example.org \
|
||||
* npx vitest run src/api/__tests__/live-stalwart-mailbox-share.test.ts
|
||||
*
|
||||
* The folder and its share are removed afterwards, also after a failed check.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
import { JMAPClient } from '../jmap-client';
|
||||
import { CAPABILITIES } from '../types';
|
||||
import { supportsMailboxSharing } from '../delegation';
|
||||
import { getMailboxShareWith, setMailboxShare } from '../mailbox-sharing';
|
||||
import { createMailbox, deleteMailbox } from '../email';
|
||||
import { listPrincipals } from '../files';
|
||||
import { directoryNotice } from '../../lib/principal-directory';
|
||||
import { detectPreset, MAILBOX_PRESETS } from '../../lib/delegation/share-rights';
|
||||
|
||||
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 USER2 = process.env.BULWARK_LIVE_USER2;
|
||||
const PASS2 = process.env.BULWARK_LIVE_PASS2 ?? PASS;
|
||||
const live = SERVER && PASS && USER2 ? describe : describe.skip;
|
||||
|
||||
let owner: JMAPClient;
|
||||
let delegate: JMAPClient;
|
||||
let folderId: string | null = null;
|
||||
|
||||
async function useOwner(): Promise<void> {
|
||||
const mod = await import('../jmap-client');
|
||||
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(owner.snapshot());
|
||||
}
|
||||
|
||||
/** The owner's folders the delegate can see. */
|
||||
async function delegateSees(): Promise<string[]> {
|
||||
const session = await delegate.refreshSession();
|
||||
if (!session.accounts?.[owner.accountId]) return [];
|
||||
const res = await delegate.request(
|
||||
[['Mailbox/get', { accountId: owner.accountId, ids: null, properties: ['id'] }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
const [method, body] = (res.methodResponses ?? [])[0] as [string, { list?: { id: string }[] }, string];
|
||||
return method === 'Mailbox/get' ? (body.list ?? []).map((m) => m.id) : [];
|
||||
}
|
||||
|
||||
live('live Stalwart: folder sharing', () => {
|
||||
beforeAll(async () => {
|
||||
owner = new JMAPClient();
|
||||
await owner.connect(SERVER!, USER, PASS);
|
||||
delegate = new JMAPClient();
|
||||
await delegate.connect(SERVER!, USER2!, PASS2);
|
||||
await useOwner();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (!owner || !folderId) return;
|
||||
await useOwner();
|
||||
await setMailboxShare(folderId, delegate.accountId, null).catch(() => undefined);
|
||||
await deleteMailbox(folderId, undefined, { onDestroyRemoveEmails: true }).catch(() => undefined);
|
||||
}, 60_000);
|
||||
|
||||
it('shares a folder read-only, upgrades it, and takes it back', async () => {
|
||||
expect(supportsMailboxSharing()).toBe(true);
|
||||
folderId = await createMailbox({ name: `Bulwark share check ${Date.now()}` });
|
||||
const principalId = delegate.accountId;
|
||||
|
||||
// Nobody yet: Stalwart answers {} (or null).
|
||||
expect(Object.keys((await getMailboxShareWith(folderId)) ?? {})).toEqual([]);
|
||||
|
||||
await setMailboxShare(folderId, principalId, MAILBOX_PRESETS.read);
|
||||
const afterRead = await getMailboxShareWith(folderId);
|
||||
expect(afterRead?.[principalId]).toBeTruthy();
|
||||
// 1.0 stores the keyword right with the seen right; still "Read only".
|
||||
expect(detectPreset('mailbox', afterRead![principalId])).toBe('read');
|
||||
expect(await delegateSees()).toContain(folderId);
|
||||
|
||||
await setMailboxShare(folderId, principalId, MAILBOX_PRESETS.readWrite);
|
||||
const afterWrite = await getMailboxShareWith(folderId);
|
||||
expect(detectPreset('mailbox', afterWrite![principalId])).toBe('readWrite');
|
||||
|
||||
await setMailboxShare(folderId, principalId, null);
|
||||
const afterRemove = await getMailboxShareWith(folderId);
|
||||
expect(afterRemove?.[principalId]).toBeFalsy();
|
||||
expect(await delegateSees()).not.toContain(folderId);
|
||||
}, 60_000);
|
||||
|
||||
// Stalwart 1.0 hides the directory unless the admin turns directory
|
||||
// queries on: the sheet then says why the list is short and names a
|
||||
// sharee it can't resolve "Unknown user". 0.16 lists everyone.
|
||||
it('either lists the person shared with or explains why the list is short', async () => {
|
||||
const listing = await listPrincipals();
|
||||
const listed = listing.principals.some((p) => p.id === delegate.accountId);
|
||||
const notice = directoryNotice(listing);
|
||||
expect(listed || notice !== null).toBe(true);
|
||||
if (!listed) expect(notice).toBe('restricted');
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'own',
|
||||
request: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { getMailboxShareWith, setMailboxShare } from '../mailbox-sharing';
|
||||
import { MAILBOX_PRESETS } from '../../lib/delegation/share-rights';
|
||||
import { canShareMailbox, mailboxShareRef } from '../../lib/mailbox-share';
|
||||
import type { Mailbox, MailboxRights } from '../types';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getMailboxShareWith', () => {
|
||||
it("asks for the folder's shareWith with the mail:share capability", async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['Mailbox/get', { list: [{ id: 'mb1', shareWith: { p2: MAILBOX_PRESETS.read } }] }, '0']],
|
||||
});
|
||||
const shareWith = await getMailboxShareWith('mb1');
|
||||
expect(shareWith).toEqual({ p2: MAILBOX_PRESETS.read });
|
||||
const [[call], using] = mockRequest.mock.calls[0];
|
||||
expect(call).toEqual(['Mailbox/get', { accountId: 'own', ids: ['mb1'], properties: ['id', 'shareWith'] }, '0']);
|
||||
expect(using).toContain('urn:ietf:params:jmap:mail:share');
|
||||
});
|
||||
|
||||
it('reads a folder of a shared account under its owner', async () => {
|
||||
mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/get', { list: [{ id: 'x', shareWith: null }] }, '0']] });
|
||||
expect(await getMailboxShareWith('x', 'team')).toBeNull();
|
||||
expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('team');
|
||||
});
|
||||
|
||||
it('fails when the folder is gone', async () => {
|
||||
mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/get', { list: [], notFound: ['mb1'] }, '0']] });
|
||||
await expect(getMailboxShareWith('mb1')).rejects.toThrow('Folder not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMailboxShare', () => {
|
||||
it('patches one principal into shareWith, and null takes the access away', async () => {
|
||||
mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/set', { updated: { mb1: null } }, '0']] });
|
||||
await setMailboxShare('mb1', 'p2', MAILBOX_PRESETS.read);
|
||||
await setMailboxShare('mb1', 'p2', null, 'team');
|
||||
expect(mockRequest.mock.calls[0][0][0][1]).toEqual({ accountId: 'own', update: { mb1: { 'shareWith/p2': MAILBOX_PRESETS.read } } });
|
||||
expect(mockRequest.mock.calls[1][0][0][1]).toEqual({ accountId: 'team', update: { mb1: { 'shareWith/p2': null } } });
|
||||
});
|
||||
|
||||
it("reports the server's refusal", async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['Mailbox/set', { notUpdated: { mb1: { type: 'forbidden', description: 'Not allowed' } } }, '0']],
|
||||
});
|
||||
await expect(setMailboxShare('mb1', 'p2', MAILBOX_PRESETS.read)).rejects.toThrow('Not allowed');
|
||||
});
|
||||
|
||||
it('fails when the server does not confirm the change', async () => {
|
||||
mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/set', { updated: {} }, '0']] });
|
||||
await expect(setMailboxShare('mb1', 'p2', MAILBOX_PRESETS.read)).rejects.toThrow('did not confirm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('which folders offer Share', () => {
|
||||
const rights = (extra: Record<string, boolean> = {}) => ({ ...MAILBOX_PRESETS.read, ...extra }) as unknown as MailboxRights;
|
||||
|
||||
it("offers it on the user's own folders where the server shares folders", () => {
|
||||
expect(canShareMailbox({ isShared: false, myRights: rights() }, true)).toBe(true);
|
||||
expect(canShareMailbox({ isShared: false, myRights: rights() }, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("offers it on someone else's folder only with their permission to pass it on", () => {
|
||||
expect(canShareMailbox({ isShared: true, myRights: rights() }, true)).toBe(false);
|
||||
expect(canShareMailbox({ isShared: true, myRights: rights({ mayShare: true }) }, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("shares a shared account's folder under its owner, with the owner's id", () => {
|
||||
const shared = { id: 'team:5', originalId: '5', isShared: true, accountId: 'team' } as Mailbox;
|
||||
expect(mailboxShareRef(shared)).toEqual({ mailboxId: '5', accountId: 'team' });
|
||||
expect(mailboxShareRef({ id: 'a', isShared: false, accountId: 'own' } as Mailbox)).toEqual({ mailboxId: 'a', accountId: undefined });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// Sharing a mail folder with other principals
|
||||
// (urn:ietf:params:jmap:mail:share), as the webmail's
|
||||
// JMAPClient.getMailboxShareWith / setMailboxShare do (lib/jmap/client.ts).
|
||||
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import { requireMethodResult } from './jmap-result';
|
||||
import { MAIL_SHARE_CAPABILITY } from './delegation';
|
||||
import type { ShareMailboxRights } from '../lib/delegation/share-rights';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, MAIL_SHARE_CAPABILITY];
|
||||
|
||||
/**
|
||||
* The current `shareWith` of one folder. Read on demand (the share sheet):
|
||||
* Stalwart leaves shareWith out of Mailbox/get unless it is asked for.
|
||||
* `accountId` is the owner's account for a folder someone shared with the
|
||||
* user, with the owner's mailbox id.
|
||||
*/
|
||||
export async function getMailboxShareWith(
|
||||
mailboxId: string,
|
||||
accountId?: string,
|
||||
): Promise<Record<string, ShareMailboxRights> | null> {
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/get', { accountId: accountId || jmapClient.accountId, ids: [mailboxId], properties: ['id', 'shareWith'] }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = requireMethodResult<{ list?: Array<{ id: string; shareWith?: Record<string, ShareMailboxRights> | null }> }>(
|
||||
res, '0', 'Mailbox/get',
|
||||
);
|
||||
const mailbox = (result.list ?? []).find((mb) => mb.id === mailboxId);
|
||||
if (!mailbox) throw new Error('Folder not found');
|
||||
return mailbox.shareWith ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants (`rights`) or revokes (`null`) a principal's access to a folder with
|
||||
* a `shareWith/{principalId}` patch, like the calendar and address book
|
||||
* shares.
|
||||
*/
|
||||
export async function setMailboxShare(
|
||||
mailboxId: string,
|
||||
principalId: string,
|
||||
rights: ShareMailboxRights | null,
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/set', {
|
||||
accountId: accountId || jmapClient.accountId,
|
||||
update: { [mailboxId]: { [`shareWith/${principalId}`]: rights } },
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = requireMethodResult<{
|
||||
updated?: Record<string, unknown> | null;
|
||||
notUpdated?: Record<string, { description?: string; type?: string }>;
|
||||
}>(res, '0', 'Mailbox/set');
|
||||
const err = result.notUpdated?.[mailboxId];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to update folder share');
|
||||
if (!result.updated || !(mailboxId in result.updated)) {
|
||||
throw new Error('Server did not confirm the share update');
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
|
||||
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
|
||||
Clock, Layers, Users, Tag, Mails, MoreHorizontal, MailOpen, StickyNote, AlarmClock, Flag,
|
||||
Clock, Layers, Users, Tag, Mails, MoreHorizontal, Share2, MailOpen, StickyNote, AlarmClock, Flag,
|
||||
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw,
|
||||
type AppIcon,
|
||||
} from './icons';
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
} from '../api/email';
|
||||
import { useTagCountsStore } from '../stores/tag-counts-store';
|
||||
import { sidebarTagRows } from '../lib/keyword-nesting';
|
||||
import { supportsMailboxSharing } from '../api/delegation';
|
||||
import { MailboxShareSheet } from './sharing/MailboxShareSheet';
|
||||
import { canShareMailbox } from '../lib/mailbox-share';
|
||||
import { tagIdFromKeyword } from '../lib/thread-utils';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
@@ -381,6 +384,8 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
title: string; message?: string; initial?: string; confirmLabel: string; onSubmit: (v: string) => void;
|
||||
} | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
// The folder whose "Share" sheet is open (webmail MailboxShareDialog).
|
||||
const [sharing, setSharing] = React.useState<{ mailbox: Mailbox; name: string } | null>(null);
|
||||
const tagCounts = useTagCountsStore((s) => s.counts);
|
||||
const tagCountsGeneration = useTagCountsStore((s) => s.generation);
|
||||
const ensureTagCounts = useTagCountsStore((s) => s.ensure);
|
||||
@@ -664,6 +669,14 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (canShareMailbox(mb, supportsMailboxSharing())) {
|
||||
actions.push({
|
||||
key: 'share',
|
||||
label: t('mailbox_context_menu.share', 'Share…'),
|
||||
icon: Share2,
|
||||
onPress: () => setSharing({ mailbox: mb, name }),
|
||||
});
|
||||
}
|
||||
if (!mb.role && mb.myRights?.mayRename !== false) {
|
||||
actions.push({
|
||||
key: 'rename',
|
||||
@@ -1316,6 +1329,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
onClose={() => setPrompt(null)}
|
||||
/>
|
||||
)}
|
||||
<MailboxShareSheet mailbox={sharing?.mailbox ?? null} name={sharing?.name} onClose={() => setSharing(null)} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1351,6 +1365,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
onClose={() => setPrompt(null)}
|
||||
/>
|
||||
)}
|
||||
<MailboxShareSheet mailbox={sharing?.mailbox ?? null} name={sharing?.name} onClose={() => setSharing(null)} />
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Checked on the source: the suite has no React Native renderer.
|
||||
const read = (file: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', file), 'utf8');
|
||||
|
||||
describe('folder sharing in the sidebar', () => {
|
||||
it('offers Share in the folder menu where the folder can be shared, and opens the share sheet', () => {
|
||||
const source = read('components/SidebarDrawer.tsx');
|
||||
expect(source).toMatch(/canShareMailbox\(mb, supportsMailboxSharing\(\)\)/);
|
||||
expect(source).toMatch(/key: 'share'/);
|
||||
expect(source).toMatch(/onPress: \(\) => setSharing\(\{ mailbox: mb, name \}\)/);
|
||||
// Both the docked sidebar and the drawer render the sheet.
|
||||
expect(source.match(/<MailboxShareSheet /g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reads and writes the share through the folder sharing calls, with the folder presets', () => {
|
||||
const source = read('components/sharing/MailboxShareSheet.tsx');
|
||||
expect(source).toMatch(/getMailboxShareWith\(mailboxId, accountId\)/);
|
||||
expect(source).toMatch(/setMailboxShare\(mailboxId, principalId, rights, accountId\)/);
|
||||
expect(source).toMatch(/detectPreset\('mailbox', rights\)/);
|
||||
expect(source).not.toMatch(/freeBusy/);
|
||||
});
|
||||
|
||||
it('lets the share sheet name a preset the server stored differently', () => {
|
||||
const source = read('components/sharing/ShareCollectionSheet.tsx');
|
||||
expect(source).toMatch(/detectPreset \? detectPreset\(rights\) : detectSharePreset\(rights, presets\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, Alert, Modal, Pressable, StyleSheet, View } from 'react-native';
|
||||
import type { Mailbox } from '../../api/types';
|
||||
import { getMailboxShareWith, setMailboxShare } from '../../api/mailbox-sharing';
|
||||
import { detectPreset, MAILBOX_PRESETS, type ShareMailboxRights } from '../../lib/delegation/share-rights';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { ShareCollectionSheet, type ShareableCollection, type SharePreset } from './ShareCollectionSheet';
|
||||
import { mailboxShareRef } from '../../lib/mailbox-share';
|
||||
|
||||
interface MailboxShareSheetProps {
|
||||
/** Null hides the sheet. */
|
||||
mailbox: Mailbox | null;
|
||||
/** The folder's name as the sidebar shows it (Inbox, Sent… localised). */
|
||||
name?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a mail folder with other people (webmail mailbox-share-dialog.tsx):
|
||||
* the folder's shareWith is read when the sheet opens, since it is not part
|
||||
* of the folder list, and then the same sheet as for calendars and address
|
||||
* books picks people and roles.
|
||||
*/
|
||||
export function MailboxShareSheet({ mailbox, name, onClose }: MailboxShareSheetProps) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const c = useColors();
|
||||
const [collection, setCollection] = React.useState<ShareableCollection<ShareMailboxRights> | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCollection(null);
|
||||
if (!mailbox) return undefined;
|
||||
let cancelled = false;
|
||||
const { mailboxId, accountId } = mailboxShareRef(mailbox);
|
||||
getMailboxShareWith(mailboxId, accountId).then(
|
||||
(shareWith) => {
|
||||
if (!cancelled) setCollection({ id: mailboxId, name: name || mailbox.name, shareWith });
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (cancelled) return;
|
||||
Alert.alert(
|
||||
t('sharing.share_failed', 'Failed to update sharing'),
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
onClose();
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// `name` and `t` only label the sheet; a new folder starts a new read.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mailbox]);
|
||||
|
||||
// The webmail's folder presets: no free/busy for mail.
|
||||
const presets = React.useMemo<SharePreset<ShareMailboxRights>[]>(() => [
|
||||
{ key: 'read', label: t('sharing.preset.read', 'Read only'), rights: MAILBOX_PRESETS.read },
|
||||
{ key: 'readWrite', label: t('sharing.preset.readWrite', 'Read & write'), rights: MAILBOX_PRESETS.readWrite },
|
||||
{ key: 'manager', label: t('sharing.preset.manager', 'Manager'), rights: MAILBOX_PRESETS.manager },
|
||||
], [t]);
|
||||
|
||||
if (!mailbox) return null;
|
||||
|
||||
if (!collection) {
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable
|
||||
style={styles.backdrop}
|
||||
onPress={onClose}
|
||||
accessibilityLabel={t('sharing.loading_principals', 'Loading users…')}
|
||||
>
|
||||
<View style={[styles.spinner, { backgroundColor: c.background }]}>
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const { accountId } = mailboxShareRef(mailbox);
|
||||
return (
|
||||
<ShareCollectionSheet
|
||||
collection={collection}
|
||||
presets={presets}
|
||||
addRights={MAILBOX_PRESETS.read}
|
||||
// Stalwart 1.0 stores a read grant with the keyword right on.
|
||||
detectPreset={(rights) => detectPreset('mailbox', rights)}
|
||||
onShare={(mailboxId, principalId, rights) => setMailboxShare(mailboxId, principalId, rights, accountId)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', alignItems: 'center', justifyContent: 'center' },
|
||||
spinner: { padding: 20, borderRadius: 12 },
|
||||
});
|
||||
@@ -55,13 +55,18 @@ interface ShareCollectionSheetProps<R extends object> {
|
||||
addRights: R;
|
||||
onShare: (collectionId: string, principalId: string, rights: R | null) => Promise<void>;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Names the preset a stored grant amounts to, when the server stores
|
||||
* something other than what was sent (a folder on Stalwart 1.0).
|
||||
*/
|
||||
detectPreset?: (rights: R) => string;
|
||||
}
|
||||
|
||||
// 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,
|
||||
collection, presets, addRights, onShare, onClose, detectPreset,
|
||||
}: ShareCollectionSheetProps<R>) {
|
||||
// Centred on a wide window, clear of side insets in landscape.
|
||||
const sheetInsets = useSheetInsets();
|
||||
@@ -224,7 +229,7 @@ export function ShareCollectionSheet<R extends object>({
|
||||
<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 preset = detectPreset ? detectPreset(rights) : detectSharePreset(rights, presets);
|
||||
const busy = savingId === principalId;
|
||||
return (
|
||||
<View key={principalId} style={styles.shareRow}>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Mailbox } from '../api/types';
|
||||
|
||||
// Folder sharing (webmail mailbox-share-dialog.tsx and the Share entry of
|
||||
// mailbox-context-menu.tsx): which folders offer it, and where it is kept.
|
||||
|
||||
/**
|
||||
* Where a folder's sharing is read and written: a folder of a shared or
|
||||
* group account lives under its owner's account with the owner's id.
|
||||
*/
|
||||
export function mailboxShareRef(mailbox: Pick<Mailbox, 'id' | 'originalId' | 'isShared' | 'accountId'>): {
|
||||
mailboxId: string;
|
||||
accountId: string | undefined;
|
||||
} {
|
||||
return {
|
||||
mailboxId: mailbox.originalId || mailbox.id,
|
||||
accountId: mailbox.isShared ? mailbox.accountId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the folder's menu offers "Share": the server takes shareWith on
|
||||
* folders (mail:share), and a folder someone shared with the user may be
|
||||
* passed on only with their permission (webmail mailbox-context-menu.tsx).
|
||||
*/
|
||||
export function canShareMailbox(
|
||||
mailbox: Pick<Mailbox, 'isShared' | 'myRights'>,
|
||||
serverSupportsSharing: boolean,
|
||||
): boolean {
|
||||
if (!serverSupportsSharing) return false;
|
||||
return !mailbox.isShared || (mailbox.myRights as { mayShare?: boolean } | undefined)?.mayShare === true;
|
||||
}
|
||||
Reference in New Issue
Block a user