mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +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 {
|
import {
|
||||||
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
|
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
|
||||||
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
|
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,
|
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw,
|
||||||
type AppIcon,
|
type AppIcon,
|
||||||
} from './icons';
|
} from './icons';
|
||||||
@@ -35,6 +35,9 @@ import {
|
|||||||
} from '../api/email';
|
} from '../api/email';
|
||||||
import { useTagCountsStore } from '../stores/tag-counts-store';
|
import { useTagCountsStore } from '../stores/tag-counts-store';
|
||||||
import { sidebarTagRows } from '../lib/keyword-nesting';
|
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 { tagIdFromKeyword } from '../lib/thread-utils';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
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;
|
title: string; message?: string; initial?: string; confirmLabel: string; onSubmit: (v: string) => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [busy, setBusy] = React.useState(false);
|
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 tagCounts = useTagCountsStore((s) => s.counts);
|
||||||
const tagCountsGeneration = useTagCountsStore((s) => s.generation);
|
const tagCountsGeneration = useTagCountsStore((s) => s.generation);
|
||||||
const ensureTagCounts = useTagCountsStore((s) => s.ensure);
|
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) {
|
if (!mb.role && mb.myRights?.mayRename !== false) {
|
||||||
actions.push({
|
actions.push({
|
||||||
key: 'rename',
|
key: 'rename',
|
||||||
@@ -1316,6 +1329,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
onClose={() => setPrompt(null)}
|
onClose={() => setPrompt(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<MailboxShareSheet mailbox={sharing?.mailbox ?? null} name={sharing?.name} onClose={() => setSharing(null)} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1351,6 +1365,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
onClose={() => setPrompt(null)}
|
onClose={() => setPrompt(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<MailboxShareSheet mailbox={sharing?.mailbox ?? null} name={sharing?.name} onClose={() => setSharing(null)} />
|
||||||
</SafeAreaModal>
|
</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;
|
addRights: R;
|
||||||
onShare: (collectionId: string, principalId: string, rights: R | null) => Promise<void>;
|
onShare: (collectionId: string, principalId: string, rights: R | null) => Promise<void>;
|
||||||
onClose: () => 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
|
// JMAP sharing for an owned collection: pick a principal, choose a role
|
||||||
// (webmail ShareCollectionDialog). The principal list comes from the same
|
// (webmail ShareCollectionDialog). The principal list comes from the same
|
||||||
// Principal/query the Files share sheet uses.
|
// Principal/query the Files share sheet uses.
|
||||||
export function ShareCollectionSheet<R extends object>({
|
export function ShareCollectionSheet<R extends object>({
|
||||||
collection, presets, addRights, onShare, onClose,
|
collection, presets, addRights, onShare, onClose, detectPreset,
|
||||||
}: ShareCollectionSheetProps<R>) {
|
}: ShareCollectionSheetProps<R>) {
|
||||||
// Centred on a wide window, clear of side insets in landscape.
|
// Centred on a wide window, clear of side insets in landscape.
|
||||||
const sheetInsets = useSheetInsets();
|
const sheetInsets = useSheetInsets();
|
||||||
@@ -224,7 +229,7 @@ export function ShareCollectionSheet<R extends object>({
|
|||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionLabel}>{t('calendar.share.shared_with', 'Shared with')}</Text>
|
<Text style={styles.sectionLabel}>{t('calendar.share.shared_with', 'Shared with')}</Text>
|
||||||
{sharedEntries.map(([principalId, rights]) => {
|
{sharedEntries.map(([principalId, rights]) => {
|
||||||
const preset = detectSharePreset(rights, presets);
|
const preset = detectPreset ? detectPreset(rights) : detectSharePreset(rights, presets);
|
||||||
const busy = savingId === principalId;
|
const busy = savingId === principalId;
|
||||||
return (
|
return (
|
||||||
<View key={principalId} style={styles.shareRow}>
|
<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