mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: show the shared tags of group mailboxes, read from their metadata on Stalwart 1.0
This commit is contained in:
@@ -1316,6 +1316,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"shared_tags": {
|
||||||
|
"description": "Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.",
|
||||||
|
"empty": "No shared tags yet.",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"title": "Shared tags",
|
||||||
|
"unsupported": "This mail server can't keep shared tags, so this mailbox shows your own tags."
|
||||||
|
},
|
||||||
"sharing": {
|
"sharing": {
|
||||||
"directory_failed": "Couldn't load the list of users and groups.",
|
"directory_failed": "Couldn't load the list of users and groups.",
|
||||||
"directory_not_browsable": "Your server doesn't let you browse its user directory.",
|
"directory_not_browsable": "Your server doesn't let you browse its user directory.",
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const METADATA = 'urn:ietf:params:jmap:metadata';
|
||||||
|
|
||||||
|
const { state } = vi.hoisted(() => ({
|
||||||
|
state: {
|
||||||
|
session: null as unknown,
|
||||||
|
requests: [] as Array<{ calls: Array<[string, Record<string, unknown>, string]>; using?: string[] }>,
|
||||||
|
answer: null as null | ((calls: Array<[string, Record<string, unknown>, string]>) => unknown),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../jmap-client', () => ({
|
||||||
|
jmapClient: {
|
||||||
|
serverUrl: 'https://mail.example.org',
|
||||||
|
get currentSession() {
|
||||||
|
return state.session;
|
||||||
|
},
|
||||||
|
request: vi.fn(async (calls: Array<[string, Record<string, unknown>, string]>, using?: string[]) => {
|
||||||
|
state.requests.push({ calls, using });
|
||||||
|
return state.answer!(calls);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata';
|
||||||
|
|
||||||
|
const VENDOR = { supportsVendorNamespaces: true, supportsPrivate: true, namespaces: [], maxDepth: 8 };
|
||||||
|
|
||||||
|
// Stalwart 1.0.0 lists Metadata per account; 0.16 has none.
|
||||||
|
function session(version: '1.0' | '0.16') {
|
||||||
|
const caps = version === '1.0' ? { [METADATA]: { dataTypes: { Mailbox: VENDOR, Email: VENDOR } } } : {};
|
||||||
|
return {
|
||||||
|
capabilities: version === '1.0' ? { [METADATA]: {} } : {},
|
||||||
|
accounts: {
|
||||||
|
own: { accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...caps } },
|
||||||
|
team: { accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...caps } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// As Stalwart 1.0.0 answers the query and the get (checked live 2026-10-29).
|
||||||
|
function inboxWith(metadata: unknown) {
|
||||||
|
return () => ({
|
||||||
|
methodResponses: [
|
||||||
|
['Mailbox/query', { accountId: 'team', ids: ['a'] }, 'q'],
|
||||||
|
['Mailbox/get', { accountId: 'team', list: [{ id: 'a', ...(metadata === undefined ? {} : { metadata }) }] }, 'g'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.requests = [];
|
||||||
|
state.answer = null;
|
||||||
|
resetBulwarkMetadataPauses();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBulwarkMetadata', () => {
|
||||||
|
it('sends nothing to an account that advertises no metadata (Stalwart 0.16)', async () => {
|
||||||
|
state.session = session('0.16');
|
||||||
|
expect(supportsBulwarkMetadata('team')).toBe(false);
|
||||||
|
expect(await getBulwarkMetadata('team')).toBeNull();
|
||||||
|
expect(state.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the Inbox's Bulwark namespaces in a request of its own", async () => {
|
||||||
|
state.session = session('1.0');
|
||||||
|
state.answer = inboxWith({
|
||||||
|
'org.bulwarkmail.tags': { tags: { urgent: { label: 'Urgent', color: 'red' } } },
|
||||||
|
'com.example.other': { x: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await getBulwarkMetadata('team')).toEqual({ tags: { tags: { urgent: { label: 'Urgent', color: 'red' } } } });
|
||||||
|
expect(state.requests).toHaveLength(1);
|
||||||
|
const [{ calls, using }] = state.requests;
|
||||||
|
expect(using).toContain(METADATA);
|
||||||
|
expect(calls.map(([name]) => name)).toEqual(['Mailbox/query', 'Mailbox/get']);
|
||||||
|
expect(calls[0][1]).toEqual({ accountId: 'team', filter: { role: 'inbox' } });
|
||||||
|
expect(calls[1][1].properties).toEqual(['id', 'metadata']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes an answer without the property for no metadata, and pauses the account', async () => {
|
||||||
|
state.session = session('1.0');
|
||||||
|
state.answer = inboxWith(undefined);
|
||||||
|
expect(await getBulwarkMetadata('team')).toBeNull();
|
||||||
|
expect(supportsBulwarkMetadata('team')).toBe(false);
|
||||||
|
expect(await getBulwarkMetadata('team')).toBeNull();
|
||||||
|
expect(state.requests).toHaveLength(1);
|
||||||
|
// Another account is not paused with it.
|
||||||
|
expect(supportsBulwarkMetadata('own')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses an account whose request fails (the pre-release build dropped it)', async () => {
|
||||||
|
state.session = session('1.0');
|
||||||
|
state.answer = () => { throw new Error('socket hang up'); };
|
||||||
|
expect(await getBulwarkMetadata('team')).toBeNull();
|
||||||
|
expect(supportsBulwarkMetadata('team')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -239,11 +239,18 @@ describe('JMAP Metadata in the request-building sources', () => {
|
|||||||
/(['"`])metadata(\/[^'"`\n]*)?\1/,
|
/(['"`])metadata(\/[^'"`\n]*)?\1/,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Stalwart 1.0.0 serves Metadata. The one module that reads it (a group's
|
||||||
|
// shared tags) asks only accounts that advertise it, in a request of its
|
||||||
|
// own; its tests cover that.
|
||||||
|
const METADATA_READER = 'src/api/bulwark-metadata.ts';
|
||||||
|
|
||||||
it('never names a JMAP Metadata property, argument or capability in code', () => {
|
it('never names a JMAP Metadata property, argument or capability in code', () => {
|
||||||
const files = [...sourceFiles('src'), 'App.tsx'];
|
const files = [...sourceFiles('src'), 'App.tsx'];
|
||||||
expect(files).toContain('src/api/email.ts');
|
expect(files).toContain('src/api/email.ts');
|
||||||
|
expect(files).toContain(METADATA_READER);
|
||||||
const hits: string[] = [];
|
const hits: string[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
if (file === METADATA_READER) continue;
|
||||||
const code = withoutComments(readFileSync(path.join(root, file), 'utf8'));
|
const code = withoutComments(readFileSync(path.join(root, file), 'utf8'));
|
||||||
for (const pattern of FORBIDDEN) {
|
for (const pattern of FORBIDDEN) {
|
||||||
const match = code.match(pattern);
|
const match = code.match(pattern);
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import {
|
|||||||
createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, validateSieveScript,
|
createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, validateSieveScript,
|
||||||
} from '../sieve';
|
} from '../sieve';
|
||||||
import { inlineContactMediaForExport, writeContactWithNewPhoto } from '../../lib/contact-media';
|
import { inlineContactMediaForExport, writeContactWithNewPhoto } from '../../lib/contact-media';
|
||||||
|
import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata';
|
||||||
|
import { parseSharedTags, SHARED_TAGS_KEY } from '../../lib/shared-tags';
|
||||||
import { generateVCard, parseVCard } from '../../lib/vcard';
|
import { generateVCard, parseVCard } from '../../lib/vcard';
|
||||||
import type { ContactCard } from '../types';
|
import type { ContactCard } from '../types';
|
||||||
|
|
||||||
@@ -352,6 +354,43 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
|
|||||||
expect(first).toHaveProperty('myRights');
|
expect(first).toHaveProperty('myRights');
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
// Shared tags (#1310) live in a group's Inbox metadata. The test login
|
||||||
|
// is in no group, so this checks the shape on its own Inbox: written the
|
||||||
|
// way the webmail writes it, read the way the app reads it.
|
||||||
|
it('reads shared tags from Inbox metadata on 1.0 and asks nothing of 0.16', async () => {
|
||||||
|
resetBulwarkMetadataPauses();
|
||||||
|
const accountId = client.accountId;
|
||||||
|
if (!is10()) {
|
||||||
|
expect(supportsBulwarkMetadata(accountId)).toBe(false);
|
||||||
|
expect(await getBulwarkMetadata(accountId)).toBeNull();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:metadata'];
|
||||||
|
const inbox = (await getMailboxes()).find((m) => m.role === 'inbox')!.id;
|
||||||
|
const before = await getBulwarkMetadata(accountId);
|
||||||
|
expect(before).not.toBeNull();
|
||||||
|
const setTags = (value: unknown, path = '') => client.request([
|
||||||
|
['Mailbox/set', { accountId, update: { [inbox]: { [`metadata/org.bulwarkmail.tags${path}`]: value } } }, '0'],
|
||||||
|
], using);
|
||||||
|
try {
|
||||||
|
const written = await setTags({ tags: { [`live-${RUN}`]: { label: 'Live check', color: 'teal-dark', note: 'kept' } } });
|
||||||
|
expect(written.methodResponses[0][1].updated).toHaveProperty(inbox);
|
||||||
|
// One tag patched on its own, as the webmail saves a change.
|
||||||
|
await setTags({ label: 'Second', color: 'blue' }, `/tags/second-${RUN}`);
|
||||||
|
|
||||||
|
resetBulwarkMetadataPauses();
|
||||||
|
const entries = await getBulwarkMetadata(accountId);
|
||||||
|
expect(parseSharedTags(entries?.[SHARED_TAGS_KEY])).toEqual([
|
||||||
|
{ id: `live-${RUN}`, label: 'Live check', color: 'teal' },
|
||||||
|
{ id: `second-${RUN}`, label: 'Second', color: 'blue' },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await setTags(before?.[SHARED_TAGS_KEY] ?? null);
|
||||||
|
}
|
||||||
|
const after = await getBulwarkMetadata(accountId);
|
||||||
|
expect(after?.[SHARED_TAGS_KEY]).toEqual(before?.[SHARED_TAGS_KEY]);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
it('creates, reads, validates and deletes a Sieve script', async () => {
|
it('creates, reads, validates and deletes a Sieve script', async () => {
|
||||||
const content = `require ["fileinto"];\r\n# ${RUN} */\r\nif header :contains "subject" "${RUN}" { fileinto "INBOX"; }\r\n`;
|
const content = `require ["fileinto"];\r\n# ${RUN} */\r\nif header :contains "subject" "${RUN}" { fileinto "INBOX"; }\r\n`;
|
||||||
expect((await validateSieveScript(content)).isValid).toBe(true);
|
expect((await validateSieveScript(content)).isValid).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Bulwark's own data in JMAP Metadata (RFC draft, urn:ietf:params:jmap:metadata)
|
||||||
|
// of an account's Inbox: the shared tag definitions of a group account
|
||||||
|
// (namespace `org.bulwarkmail.tags`). Port of the reading half of the
|
||||||
|
// webmail's lib/jmap/bulwark-metadata.ts and client.getBulwarkMetadata.
|
||||||
|
//
|
||||||
|
// Stalwart 1.0.0 serves Metadata; 0.16 advertises none, and a pre-release
|
||||||
|
// 1.0 build advertised it but dropped the connection on a Mailbox/get that
|
||||||
|
// selects it (42751a6b). So this is the only module that asks for it: only
|
||||||
|
// for an account whose capabilities offer vendor namespaces on mailboxes,
|
||||||
|
// in a request of its own, and any failure pauses that account for ten
|
||||||
|
// minutes. Every other Mailbox/get keeps its explicit property list.
|
||||||
|
|
||||||
|
import { jmapClient } from './jmap-client';
|
||||||
|
import { CAPABILITIES } from './types';
|
||||||
|
|
||||||
|
export const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
|
||||||
|
export const BULWARK_NAMESPACE = 'org.bulwarkmail';
|
||||||
|
|
||||||
|
const PAUSE_AFTER_FAILURE_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the account's capabilities offer vendor-namespaced metadata on
|
||||||
|
* mailboxes. Says nothing about whether the server really implements it.
|
||||||
|
*/
|
||||||
|
export function advertisesMailboxMetadata(accountCapabilities: Record<string, unknown> | undefined): boolean {
|
||||||
|
const cap = accountCapabilities?.[METADATA_CAPABILITY] as
|
||||||
|
| { dataTypes?: Record<string, { supportsVendorNamespaces?: boolean }> }
|
||||||
|
| undefined;
|
||||||
|
return cap?.dataTypes?.Mailbox?.supportsVendorNamespaces === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulwark's entries of a `metadata` object, by key (`tags`, `template.<id>`). */
|
||||||
|
export function bulwarkEntries(metadata: unknown): Record<string, unknown> {
|
||||||
|
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {};
|
||||||
|
const prefix = `${BULWARK_NAMESPACE}.`;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [namespace, value] of Object.entries(metadata as Record<string, unknown>)) {
|
||||||
|
if (namespace.startsWith(prefix)) out[namespace.slice(prefix.length)] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accounts whose last read failed, by server and account, with when.
|
||||||
|
const failedAt = new Map<string, number>();
|
||||||
|
const pauseKey = (accountId: string) => `${jmapClient.serverUrl ?? ''}|${accountId}`;
|
||||||
|
|
||||||
|
/** Whether the app may ask this account for its Inbox metadata now. */
|
||||||
|
export function supportsBulwarkMetadata(accountId: string): boolean {
|
||||||
|
const failed = failedAt.get(pauseKey(accountId));
|
||||||
|
if (failed !== undefined && Date.now() - failed < PAUSE_AFTER_FAILURE_MS) return false;
|
||||||
|
const account = jmapClient.currentSession?.accounts?.[accountId] as
|
||||||
|
| { accountCapabilities?: Record<string, unknown> }
|
||||||
|
| undefined;
|
||||||
|
return advertisesMailboxMetadata(account?.accountCapabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulwark's shared metadata entries of the account's Inbox, by key. Null
|
||||||
|
* when the account has none to offer (0.16) or the read failed; no request
|
||||||
|
* goes out for an account that doesn't advertise Metadata.
|
||||||
|
*/
|
||||||
|
export async function getBulwarkMetadata(accountId: string): Promise<Record<string, unknown> | null> {
|
||||||
|
if (!supportsBulwarkMetadata(accountId)) return null;
|
||||||
|
const property = 'metadata';
|
||||||
|
try {
|
||||||
|
const res = await jmapClient.request([
|
||||||
|
['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'q'],
|
||||||
|
['Mailbox/get', {
|
||||||
|
accountId,
|
||||||
|
'#ids': { resultOf: 'q', name: 'Mailbox/query', path: '/ids' },
|
||||||
|
properties: ['id', property],
|
||||||
|
}, 'g'],
|
||||||
|
], [CAPABILITIES.CORE, CAPABILITIES.MAIL, METADATA_CAPABILITY]);
|
||||||
|
const get = res.methodResponses?.find((r) => r[0] === 'Mailbox/get');
|
||||||
|
const inbox = (get?.[1] as { list?: Array<Record<string, unknown>> } | undefined)?.list?.[0];
|
||||||
|
// An answer without the property is a server that doesn't keep it,
|
||||||
|
// never "every entry was removed".
|
||||||
|
if (!inbox || !(property in inbox)) throw new Error('No Inbox metadata in the answer');
|
||||||
|
failedAt.delete(pauseKey(accountId));
|
||||||
|
return bulwarkEntries(inbox[property]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[bulwark-metadata] unavailable for account', accountId, err);
|
||||||
|
failedAt.set(pauseKey(accountId), Date.now());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the failure pauses (sign-out, tests). */
|
||||||
|
export function resetBulwarkMetadataPauses(): void {
|
||||||
|
failedAt.clear();
|
||||||
|
}
|
||||||
@@ -84,6 +84,7 @@ export function MessageContent({
|
|||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onAddressPress={onAddressPress}
|
onAddressPress={onAddressPress}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
|
jmapAccountId={jmapAccountId}
|
||||||
/>
|
/>
|
||||||
{attachmentPosition === 'beside-sender' && chips}
|
{attachmentPosition === 'beside-sender' && chips}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useColors } from '../../theme/colors';
|
|||||||
import SenderAvatar from '../SenderAvatar';
|
import SenderAvatar from '../SenderAvatar';
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { useKeywordsStore } from '../../stores/keywords-store';
|
import { useAccountKeywordDefs } from '../../stores/keywords-store';
|
||||||
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
|
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
|
||||||
import {
|
import {
|
||||||
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
|
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
|
||||||
@@ -30,6 +30,8 @@ interface Props {
|
|||||||
onAddressPress: (address: EmailAddress) => void;
|
onAddressPress: (address: EmailAddress) => void;
|
||||||
/** Compact variant for collapsed thread cards. */
|
/** Compact variant for collapsed thread cards. */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** JMAP account the message lives in (a group's shared tags name its tags). */
|
||||||
|
jmapAccountId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addressLabel(a: EmailAddress): string {
|
function addressLabel(a: EmailAddress): string {
|
||||||
@@ -94,13 +96,13 @@ function DetailRow({ label, value, styles, mono }: { label: string; value?: stri
|
|||||||
* "Show details" panel (recipients & routing, authentication, identifiers,
|
* "Show details" panel (recipients & routing, authentication, identifiers,
|
||||||
* mailing list, properties) the webmail viewer shows.
|
* mailing list, properties) the webmail viewer shows.
|
||||||
*/
|
*/
|
||||||
export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact }: Props) {
|
export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact, jmapAccountId }: Props) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
const locale = useLocaleStore((s) => s.locale);
|
const locale = useLocaleStore((s) => s.locale);
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
const keywordDefs = useAccountKeywordDefs(jmapAccountId ?? email.jmapAccountId);
|
||||||
const showVerificationCodes = useSettingsStore((s) => s.showVerificationCodes);
|
const showVerificationCodes = useSettingsStore((s) => s.showVerificationCodes);
|
||||||
const [showDetails, setShowDetails] = React.useState(false);
|
const [showDetails, setShowDetails] = React.useState(false);
|
||||||
React.useEffect(() => { setShowDetails(false); }, [email.id]);
|
React.useEffect(() => { setShowDetails(false); }, [email.id]);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from '../icons'
|
|||||||
import { SettingsSection } from './settings-section';
|
import { SettingsSection } from './settings-section';
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
|
import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../../stores/keywords-store';
|
||||||
|
import { useEmailStore } from '../../stores/email-store';
|
||||||
|
import { supportsBulwarkMetadata } from '../../api/bulwark-metadata';
|
||||||
import { DARK_COLORS } from '../../theme/tokens';
|
import { DARK_COLORS } from '../../theme/tokens';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { discoverKeywords } from '../../api/keyword-discovery';
|
import { discoverKeywords } from '../../api/keyword-discovery';
|
||||||
@@ -203,10 +205,72 @@ export function KeywordSettings() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
<SharedTagsSection />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared tags of each group mailbox in the sidebar (#1310): what every
|
||||||
|
* member sees on its mail, read from the group's Inbox metadata. Shown, not
|
||||||
|
* edited here; Bulwark on the web edits them.
|
||||||
|
*/
|
||||||
|
function SharedTagsSection() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||||
|
const sharedTags = useKeywordsStore((s) => s.sharedTags);
|
||||||
|
const loadSharedTags = useKeywordsStore((s) => s.loadSharedTags);
|
||||||
|
const accounts = React.useMemo(() => {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const m of mailboxes) {
|
||||||
|
if (m.isShared && m.accountId && !seen.has(m.accountId)) seen.set(m.accountId, m.accountName ?? m.accountId);
|
||||||
|
}
|
||||||
|
return [...seen].map(([id, name]) => ({ id, name }));
|
||||||
|
}, [mailboxes]);
|
||||||
|
useEffect(() => {
|
||||||
|
for (const account of accounts) void loadSharedTags(account.id);
|
||||||
|
}, [accounts, loadSharedTags]);
|
||||||
|
|
||||||
|
if (accounts.length === 0) return null;
|
||||||
|
const login = currentTagsLogin();
|
||||||
|
return (
|
||||||
|
<View style={styles.sharedBlock}>
|
||||||
|
<Text style={styles.sharedTitle}>{t('shared_tags.title', 'Shared tags')}</Text>
|
||||||
|
<Text style={styles.scanStatus}>
|
||||||
|
{t('shared_tags.description', 'Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.')}
|
||||||
|
</Text>
|
||||||
|
{accounts.map((account) => {
|
||||||
|
const entry = sharedTags[account.id];
|
||||||
|
const tags = entry && entry.login === login ? entry.tags : null;
|
||||||
|
return (
|
||||||
|
<View key={account.id} style={styles.sharedAccount}>
|
||||||
|
<Text style={styles.sharedAccountName}>{account.name}</Text>
|
||||||
|
{tags === null ? (
|
||||||
|
<Text style={styles.scanStatus}>
|
||||||
|
{supportsBulwarkMetadata(account.id)
|
||||||
|
? t('shared_tags.loading', 'Loading…')
|
||||||
|
: t('shared_tags.unsupported', "This mail server can't keep shared tags, so this mailbox shows your own tags.")}
|
||||||
|
</Text>
|
||||||
|
) : tags.length === 0 ? (
|
||||||
|
<Text style={styles.scanStatus}>{t('shared_tags.empty', 'No shared tags yet.')}</Text>
|
||||||
|
) : (
|
||||||
|
tags.map((kw) => (
|
||||||
|
<View key={kw.id} style={styles.kwRow}>
|
||||||
|
<View style={[styles.kwDot, { backgroundColor: c.tags[kw.color]?.dot ?? c.textMuted }]} />
|
||||||
|
<Text style={styles.kwLabel}>{kw.label}</Text>
|
||||||
|
<Text style={styles.kwId}>$label:{kw.id}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface KeywordFormProps {
|
interface KeywordFormProps {
|
||||||
initial?: Keyword;
|
initial?: Keyword;
|
||||||
existingIds: string[];
|
existingIds: string[];
|
||||||
@@ -335,6 +399,10 @@ function makeStyles(c: ThemePalette) {
|
|||||||
outlineBtnText: { ...typography.caption, color: c.mutedForeground },
|
outlineBtnText: { ...typography.caption, color: c.mutedForeground },
|
||||||
scanStatus: { ...typography.caption, color: c.mutedForeground, paddingTop: spacing.xs },
|
scanStatus: { ...typography.caption, color: c.mutedForeground, paddingTop: spacing.xs },
|
||||||
scanResults: { gap: spacing.sm },
|
scanResults: { gap: spacing.sm },
|
||||||
|
sharedBlock: { gap: spacing.sm, marginTop: spacing.lg },
|
||||||
|
sharedTitle: { ...typography.bodySemibold, color: c.text },
|
||||||
|
sharedAccount: { gap: spacing.sm },
|
||||||
|
sharedAccountName: { ...typography.caption, color: c.textSecondary, fontWeight: '600' },
|
||||||
form: {
|
form: {
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { keywordColorFromPalette, mergeSharedTags, parseSharedTags } from '../shared-tags';
|
||||||
|
|
||||||
|
describe('parseSharedTags', () => {
|
||||||
|
// The map shape the webmail writes (lib/shared-tags.ts).
|
||||||
|
it('reads the map by tag id, sorted by name, keeping unknown fields out', () => {
|
||||||
|
expect(parseSharedTags({
|
||||||
|
tags: {
|
||||||
|
urgent: { label: 'Urgent', color: 'red-dark', visibility: 'show', by: 'x' },
|
||||||
|
billing: { label: ' Billing ', color: 'teal' },
|
||||||
|
unnamed: { color: 'blue' },
|
||||||
|
},
|
||||||
|
version: 2,
|
||||||
|
})).toEqual([
|
||||||
|
{ id: 'billing', label: 'Billing', color: 'teal' },
|
||||||
|
{ id: 'unnamed', label: 'unnamed', color: 'blue' },
|
||||||
|
{ id: 'urgent', label: 'Urgent', color: 'red' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the earlier list and bare-array shapes, once per id', () => {
|
||||||
|
const list = [{ id: 'a', label: 'A', color: 'green' }, { id: 'A', label: 'dup' }, { label: 'no id' }];
|
||||||
|
expect(parseSharedTags({ list })).toEqual([{ id: 'a', label: 'A', color: 'green' }]);
|
||||||
|
expect(parseSharedTags(list)).toEqual([{ id: 'a', label: 'A', color: 'green' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops ids no keyword can hold and caps long names', () => {
|
||||||
|
const tags = parseSharedTags({
|
||||||
|
tags: { 'has space': { label: 'x' }, 'a*b': { label: 'y' }, ok: { label: 'z'.repeat(50) } },
|
||||||
|
});
|
||||||
|
expect(tags.map((t) => t.id)).toEqual(['ok']);
|
||||||
|
expect(tags[0].label).toHaveLength(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads nothing from junk', () => {
|
||||||
|
expect(parseSharedTags(undefined)).toEqual([]);
|
||||||
|
expect(parseSharedTags('tags')).toEqual([]);
|
||||||
|
expect(parseSharedTags({ tags: [1, 2] })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keywordColorFromPalette', () => {
|
||||||
|
it("maps the webmail's shades onto the app's colours and unknown ones to grey", () => {
|
||||||
|
expect(keywordColorFromPalette('blue-light')).toBe('blue');
|
||||||
|
expect(keywordColorFromPalette('amber')).toBe('amber');
|
||||||
|
expect(keywordColorFromPalette('magenta')).toBe('gray');
|
||||||
|
expect(keywordColorFromPalette(7)).toBe('gray');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeSharedTags', () => {
|
||||||
|
it('lets the shared definition name a tag and keeps the own ones nobody shared', () => {
|
||||||
|
const shared = [{ id: 'Red', label: 'Escalated', color: 'red' as const }];
|
||||||
|
const personal = [
|
||||||
|
{ id: 'red', label: 'Red', color: 'red' as const },
|
||||||
|
{ id: 'blue', label: 'Blue', color: 'blue' as const },
|
||||||
|
];
|
||||||
|
expect(mergeSharedTags(shared, personal)).toEqual([
|
||||||
|
{ id: 'Red', label: 'Escalated', color: 'red' },
|
||||||
|
{ id: 'blue', label: 'Blue', color: 'blue' },
|
||||||
|
]);
|
||||||
|
expect(mergeSharedTags([], personal)).toBe(personal);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Shared tags of a group account: the tag names and colours every member of
|
||||||
|
// a shared mailbox sees, kept in the metadata of the group's Inbox
|
||||||
|
// (namespace `org.bulwarkmail.tags`, src/api/bulwark-metadata.ts). Port of
|
||||||
|
// the reading half of the webmail's lib/shared-tags.ts (#1310, #470).
|
||||||
|
//
|
||||||
|
// Keywords in a group account are the same for every member, so
|
||||||
|
// `$label:urgent` on a team message is the team's tag; the shared
|
||||||
|
// definitions name it for everyone. Stored as a map by tag id,
|
||||||
|
// `{ "tags": { "<id>": { "label", "color", ... } } }`; the earlier
|
||||||
|
// `{ "list": [...] }` and bare-array shapes are read too. The app only
|
||||||
|
// reads them: editing them is the webmail's (Settings > Tags).
|
||||||
|
|
||||||
|
import { colors } from '../theme/tokens';
|
||||||
|
import type { KeywordColor, KeywordDef } from '../stores/keywords-store';
|
||||||
|
|
||||||
|
// keywords-store's FALLBACK_KEYWORD_COLOR; not imported, that store imports this module.
|
||||||
|
const FALLBACK_KEYWORD_COLOR: KeywordColor = 'gray';
|
||||||
|
|
||||||
|
/** The metadata key (namespace `org.bulwarkmail.tags`). */
|
||||||
|
export const SHARED_TAGS_KEY = 'tags';
|
||||||
|
|
||||||
|
/** Longest name a shared tag keeps, as in the webmail's tag editor. */
|
||||||
|
export const MAX_SHARED_TAG_LABEL = 30;
|
||||||
|
|
||||||
|
// RFC 8621 keywords are at most 128 bytes, `$label:` included.
|
||||||
|
const MAX_KEYWORD_ID_LENGTH = 128 - '$label:'.length;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characters RFC 8621 keywords can't hold (and whitespace), so a tag id
|
||||||
|
* from the server that could never be on a message is dropped.
|
||||||
|
*/
|
||||||
|
const BAD_ID_CHARS = /[\s(){\]%*"\\]/;
|
||||||
|
|
||||||
|
function isKeywordSafe(id: string): boolean {
|
||||||
|
if (!id || id.length > MAX_KEYWORD_ID_LENGTH || BAD_ID_CHARS.test(id)) return false;
|
||||||
|
for (let i = 0; i < id.length; i++) {
|
||||||
|
const code = id.charCodeAt(i);
|
||||||
|
if (code < 0x20 || code === 0x7f) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's colour for a webmail palette key. The webmail has a light and a
|
||||||
|
* dark shade of each colour (`blue-light`, `blue-dark`); the app has one.
|
||||||
|
* A colour neither knows shows grey.
|
||||||
|
*/
|
||||||
|
export function keywordColorFromPalette(color: unknown): KeywordColor {
|
||||||
|
if (typeof color !== 'string') return FALLBACK_KEYWORD_COLOR;
|
||||||
|
const base = color.replace(/-(?:light|dark)$/, '');
|
||||||
|
return Object.prototype.hasOwnProperty.call(colors.tags, base) ? base as KeywordColor : FALLBACK_KEYWORD_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The raw entry of each tag by id, from any of the stored shapes. */
|
||||||
|
function entriesOf(value: unknown): Record<string, Record<string, unknown>> {
|
||||||
|
const entries: Record<string, Record<string, unknown>> = {};
|
||||||
|
if (isObject(value) && isObject(value.tags)) {
|
||||||
|
for (const [id, entry] of Object.entries(value.tags)) {
|
||||||
|
if (isKeywordSafe(id) && isObject(entry)) entries[id] = entry;
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
const items = Array.isArray(value) ? value : isObject(value) && Array.isArray(value.list) ? value.list : [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of items) {
|
||||||
|
if (!isObject(item) || typeof item.id !== 'string') continue;
|
||||||
|
const id = item.id.trim();
|
||||||
|
if (!isKeywordSafe(id) || seen.has(id.toLowerCase())) continue;
|
||||||
|
seen.add(id.toLowerCase());
|
||||||
|
entries[id] = item;
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared definitions in the namespace's value, by name. */
|
||||||
|
export function parseSharedTags(value: unknown): KeywordDef[] {
|
||||||
|
return Object.entries(entriesOf(value))
|
||||||
|
.map(([id, entry]) => {
|
||||||
|
const label = typeof entry.label === 'string' ? entry.label.trim().slice(0, MAX_SHARED_TAG_LABEL) : '';
|
||||||
|
return { id, label: label || id, color: keywordColorFromPalette(entry.color) };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a message of a group account is tagged with: the shared definitions
|
||||||
|
* first, then the member's own for ids nobody shared, so a tag on a team
|
||||||
|
* message never loses its name just because the team didn't define it.
|
||||||
|
* Ids compare without case, as keywords do.
|
||||||
|
*/
|
||||||
|
export function mergeSharedTags(shared: readonly KeywordDef[], personal: readonly KeywordDef[]): KeywordDef[] {
|
||||||
|
if (shared.length === 0) return personal as KeywordDef[];
|
||||||
|
const named = new Set(shared.map((d) => d.id.toLowerCase()));
|
||||||
|
return [...shared, ...personal.filter((d) => !named.has(d.id.toLowerCase()))];
|
||||||
|
}
|
||||||
@@ -28,11 +28,12 @@ import type { LoadListAttachments } from '../lib/list-attachments';
|
|||||||
import { useNetworkStore } from '../stores/network-store';
|
import { useNetworkStore } from '../stores/network-store';
|
||||||
import {
|
import {
|
||||||
useEmailStore, effectiveFolderScope, spansAccounts, accountIdOfRow, deleteDestroysAcrossAccounts,
|
useEmailStore, effectiveFolderScope, spansAccounts, accountIdOfRow, deleteDestroysAcrossAccounts,
|
||||||
searchSkipsTrashAndJunk, isTrashOrJunkRole, SCOPE_EXCEPT_TRASH_JUNK,
|
searchSkipsTrashAndJunk, isTrashOrJunkRole, SCOPE_EXCEPT_TRASH_JUNK, spannedAccounts,
|
||||||
type EmailFilters,
|
type EmailFilters,
|
||||||
} from '../stores/email-store';
|
} from '../stores/email-store';
|
||||||
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/settings-store';
|
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/settings-store';
|
||||||
import { useKeywordsStore, type KeywordDef } from '../stores/keywords-store';
|
import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../stores/keywords-store';
|
||||||
|
import { mergeSharedTags } from '../lib/shared-tags';
|
||||||
import { useBackWhileFocused } from '../lib/use-back-while-focused';
|
import { useBackWhileFocused } from '../lib/use-back-while-focused';
|
||||||
import { listBackStep } from '../lib/list-back';
|
import { listBackStep } from '../lib/list-back';
|
||||||
import { reportFailure } from '../lib/report-failure';
|
import { reportFailure } from '../lib/report-failure';
|
||||||
@@ -476,6 +477,24 @@ export default function EmailListScreen({
|
|||||||
const currentRole = currentMailbox?.role ?? null;
|
const currentRole = currentMailbox?.role ?? null;
|
||||||
// The JMAP account behind the open folder (undefined = the user's own).
|
// The JMAP account behind the open folder (undefined = the user's own).
|
||||||
const currentOwnerAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
const currentOwnerAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
||||||
|
|
||||||
|
// A group account's messages carry its shared tags (#1310): read them for
|
||||||
|
// every shared account in the sidebar, and name a row's tags by the
|
||||||
|
// account it lives in.
|
||||||
|
const sharedTags = useKeywordsStore((s) => s.sharedTags);
|
||||||
|
const loadSharedTags = useKeywordsStore((s) => s.loadSharedTags);
|
||||||
|
React.useEffect(() => {
|
||||||
|
for (const accountId of spannedAccounts(mailboxes)) if (accountId) void loadSharedTags(accountId);
|
||||||
|
}, [mailboxes, loadSharedTags]);
|
||||||
|
const keywordDefsByAccount = React.useMemo(() => {
|
||||||
|
const login = currentTagsLogin();
|
||||||
|
const out = new Map<string, KeywordDef[]>();
|
||||||
|
for (const [accountId, entry] of Object.entries(sharedTags)) {
|
||||||
|
if (entry.login === login) out.set(accountId, mergeSharedTags(entry.tags, keywordDefs));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [sharedTags, keywordDefs]);
|
||||||
|
const folderKeywordDefs = (currentOwnerAccountId && keywordDefsByAccount.get(currentOwnerAccountId)) || keywordDefs;
|
||||||
const inJunk = currentRole === 'junk' || currentRole === 'spam';
|
const inJunk = currentRole === 'junk' || currentRole === 'spam';
|
||||||
const showRecipient = currentRole === 'sent' || currentRole === 'drafts';
|
const showRecipient = currentRole === 'sent' || currentRole === 'drafts';
|
||||||
|
|
||||||
@@ -788,7 +807,7 @@ export default function EmailListScreen({
|
|||||||
showPreview={showPreview}
|
showPreview={showPreview}
|
||||||
showRecipient={showRecipient}
|
showRecipient={showRecipient}
|
||||||
tagIds={rowTagIds.get(key) ?? ''}
|
tagIds={rowTagIds.get(key) ?? ''}
|
||||||
keywordDefs={keywordDefs}
|
keywordDefs={(item.jmapAccountId && keywordDefsByAccount.get(item.jmapAccountId)) || folderKeywordDefs}
|
||||||
disableAvatarImages={inJunk && !showAvatarsInJunk}
|
disableAvatarImages={inJunk && !showAvatarsInJunk}
|
||||||
answered={flags?.answered ?? false}
|
answered={flags?.answered ?? false}
|
||||||
forwarded={flags?.forwarded ?? false}
|
forwarded={flags?.forwarded ?? false}
|
||||||
@@ -805,7 +824,7 @@ export default function EmailListScreen({
|
|||||||
[
|
[
|
||||||
selectedIds, activeRowKey, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction,
|
selectedIds, activeRowKey, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction,
|
||||||
swipeMode, handleRowSwipe, disableThreading, rowFlags, rowTagIds, threadCountFor,
|
swipeMode, handleRowSwipe, disableThreading, rowFlags, rowTagIds, threadCountFor,
|
||||||
showPreview, showRecipient, keywordDefs, inJunk, showAvatarsInJunk,
|
showPreview, showRecipient, keywordDefsByAccount, folderKeywordDefs, inJunk, showAvatarsInJunk,
|
||||||
loadAttachments, openAttachment,
|
loadAttachments, openAttachment,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1020,8 +1039,8 @@ export default function EmailListScreen({
|
|||||||
const keywordFilterLabel = React.useMemo(() => {
|
const keywordFilterLabel = React.useMemo(() => {
|
||||||
if (!filters.keyword) return null;
|
if (!filters.keyword) return null;
|
||||||
const id = filters.keyword.replace(/^\$label:/, '').replace(/^\$color:/, '');
|
const id = filters.keyword.replace(/^\$label:/, '').replace(/^\$color:/, '');
|
||||||
return keywordDefs.find((k) => k.id === id)?.label ?? id;
|
return folderKeywordDefs.find((k) => k.id === id)?.label ?? id;
|
||||||
}, [filters.keyword, keywordDefs]);
|
}, [filters.keyword, folderKeywordDefs]);
|
||||||
const setFolderScope = (scope: string) => {
|
const setFolderScope = (scope: string) => {
|
||||||
const updated: EmailFilters = { ...filters };
|
const updated: EmailFilters = { ...filters };
|
||||||
// The scope a search gets by default stays unset, so it keeps following
|
// The scope a search gets by default stays unset, so it keeps following
|
||||||
@@ -1909,7 +1928,7 @@ export default function EmailListScreen({
|
|||||||
<TagSheet
|
<TagSheet
|
||||||
visible={tagSheetOpen}
|
visible={tagSheetOpen}
|
||||||
onClose={() => setTagSheetOpen(false)}
|
onClose={() => setTagSheetOpen(false)}
|
||||||
keywords={keywordDefs}
|
keywords={folderKeywordDefs}
|
||||||
selectedEmails={selectedEmails}
|
selectedEmails={selectedEmails}
|
||||||
onToggle={handleBatchTagToggle}
|
onToggle={handleBatchTagToggle}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
type FlagsHint,
|
type FlagsHint,
|
||||||
} from '../lib/email-detail-cache';
|
} from '../lib/email-detail-cache';
|
||||||
import { shareEmailEml } from '../lib/email-export';
|
import { shareEmailEml } from '../lib/email-export';
|
||||||
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
import { useKeywordsStore, useAccountKeywordDefs, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||||
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
|
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
|
||||||
import { useLayout } from '../lib/use-layout';
|
import { useLayout } from '../lib/use-layout';
|
||||||
@@ -336,7 +336,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
[bottomActions],
|
[bottomActions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
// A group account's messages are tagged with its shared tags (#1310).
|
||||||
|
const keywordDefs = useAccountKeywordDefs(ownerAccountId);
|
||||||
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
||||||
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
||||||
React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
|
React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const { client, metadata } = vi.hoisted(() => ({
|
||||||
|
client: { username: 'me@example.org' as string | null, serverUrl: 'https://mail.example.org', connectedAccountId: 'own' },
|
||||||
|
metadata: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../api/jmap-client', () => ({ jmapClient: client }));
|
||||||
|
vi.mock('../../api/bulwark-metadata', () => ({ getBulwarkMetadata: metadata }));
|
||||||
|
|
||||||
|
import { useKeywordsStore, currentTagsLogin } from '../keywords-store';
|
||||||
|
|
||||||
|
const TEAM_TAGS = { tags: { tags: { urgent: { label: 'Urgent', color: 'red' } } } };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
metadata.mockReset();
|
||||||
|
client.username = 'me@example.org';
|
||||||
|
useKeywordsStore.setState({ sharedTags: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadSharedTags', () => {
|
||||||
|
it("reads a group account's shared tags and stamps them with the login", async () => {
|
||||||
|
metadata.mockResolvedValue(TEAM_TAGS);
|
||||||
|
await useKeywordsStore.getState().loadSharedTags('team');
|
||||||
|
expect(metadata).toHaveBeenCalledWith('team');
|
||||||
|
const entry = useKeywordsStore.getState().sharedTags.team;
|
||||||
|
expect(entry.tags).toEqual([{ id: 'urgent', label: 'Urgent', color: 'red' }]);
|
||||||
|
expect(entry.login).toBe(currentTagsLogin());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never asks for the user's own account, and doesn't ask again while fresh", async () => {
|
||||||
|
metadata.mockResolvedValue(TEAM_TAGS);
|
||||||
|
await useKeywordsStore.getState().loadSharedTags('own');
|
||||||
|
expect(metadata).not.toHaveBeenCalled();
|
||||||
|
await useKeywordsStore.getState().loadSharedTags('team');
|
||||||
|
await useKeywordsStore.getState().loadSharedTags('team');
|
||||||
|
expect(metadata).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the last good tags when a read fails', async () => {
|
||||||
|
metadata.mockResolvedValueOnce(TEAM_TAGS).mockResolvedValueOnce(null);
|
||||||
|
await useKeywordsStore.getState().loadSharedTags('team');
|
||||||
|
await useKeywordsStore.getState().loadSharedTags('team', { force: true });
|
||||||
|
expect(useKeywordsStore.getState().sharedTags.team.tags).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an answer that arrives after the user switched to another login', async () => {
|
||||||
|
let resolve!: (v: unknown) => void;
|
||||||
|
metadata.mockReturnValue(new Promise((r) => { resolve = r; }));
|
||||||
|
const loading = useKeywordsStore.getState().loadSharedTags('team');
|
||||||
|
client.username = 'other@example.org';
|
||||||
|
resolve(TEAM_TAGS);
|
||||||
|
await loading;
|
||||||
|
expect(useKeywordsStore.getState().sharedTags.team).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { colors } from '../theme/tokens';
|
import { colors } from '../theme/tokens';
|
||||||
|
import { jmapClient } from '../api/jmap-client';
|
||||||
|
import { getBulwarkMetadata } from '../api/bulwark-metadata';
|
||||||
|
import { SHARED_TAGS_KEY, mergeSharedTags, parseSharedTags } from '../lib/shared-tags';
|
||||||
|
|
||||||
const STORAGE_KEY = 'webmail:keywords:v1';
|
const STORAGE_KEY = 'webmail:keywords:v1';
|
||||||
|
|
||||||
@@ -37,9 +41,28 @@ export function keywordToken(id: string): string {
|
|||||||
return `$label:${id}`;
|
return `$label:${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A group account's shared tags (lib/shared-tags.ts), as last read. */
|
||||||
|
export interface SharedTagsEntry {
|
||||||
|
/** Server and user that read them: account ids repeat across servers. */
|
||||||
|
login: string;
|
||||||
|
tags: KeywordDef[];
|
||||||
|
loadedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared tags older than this are read again when a view asks for them. */
|
||||||
|
export const SHARED_TAGS_STALE_MS = 2 * 60_000;
|
||||||
|
|
||||||
interface KeywordsState {
|
interface KeywordsState {
|
||||||
keywords: KeywordDef[];
|
keywords: KeywordDef[];
|
||||||
hydrated: boolean;
|
hydrated: boolean;
|
||||||
|
/** Shared tag definitions of the group accounts, by JMAP account id. */
|
||||||
|
sharedTags: Record<string, SharedTagsEntry>;
|
||||||
|
/**
|
||||||
|
* Read a group account's shared tags from its Inbox metadata, unless they
|
||||||
|
* were read for this login in the last two minutes. Nothing goes out for
|
||||||
|
* the user's own account or one that keeps no metadata (Stalwart 0.16).
|
||||||
|
*/
|
||||||
|
loadSharedTags: (accountId: string, options?: { force?: boolean }) => Promise<void>;
|
||||||
hydrate: () => Promise<void>;
|
hydrate: () => Promise<void>;
|
||||||
add: (kw: KeywordDef) => void;
|
add: (kw: KeywordDef) => void;
|
||||||
update: (id: string, patch: Partial<Omit<KeywordDef, 'id'>>) => void;
|
update: (id: string, patch: Partial<Omit<KeywordDef, 'id'>>) => void;
|
||||||
@@ -47,6 +70,15 @@ interface KeywordsState {
|
|||||||
resetDefaults: () => void;
|
resetDefaults: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The login the JMAP client is connected as, '' before a session. */
|
||||||
|
export function currentTagsLogin(): string {
|
||||||
|
const user = jmapClient.username;
|
||||||
|
const server = jmapClient.serverUrl;
|
||||||
|
return user && server ? `${server}|${user}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedInflight = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
function persist(keywords: KeywordDef[]): void {
|
function persist(keywords: KeywordDef[]): void {
|
||||||
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keywords)).catch((err) => {
|
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keywords)).catch((err) => {
|
||||||
console.warn('[keywords-store] persist failed', err);
|
console.warn('[keywords-store] persist failed', err);
|
||||||
@@ -56,6 +88,34 @@ function persist(keywords: KeywordDef[]): void {
|
|||||||
export const useKeywordsStore = create<KeywordsState>((set, get) => ({
|
export const useKeywordsStore = create<KeywordsState>((set, get) => ({
|
||||||
keywords: DEFAULT_KEYWORDS,
|
keywords: DEFAULT_KEYWORDS,
|
||||||
hydrated: false,
|
hydrated: false,
|
||||||
|
sharedTags: {},
|
||||||
|
|
||||||
|
loadSharedTags: (accountId, options) => {
|
||||||
|
const login = currentTagsLogin();
|
||||||
|
if (!login || !accountId || accountId === jmapClient.connectedAccountId) return Promise.resolve();
|
||||||
|
const current = get().sharedTags[accountId];
|
||||||
|
if (!options?.force && current?.login === login && Date.now() - current.loadedAt < SHARED_TAGS_STALE_MS) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
const key = `${login}|${accountId}`;
|
||||||
|
const running = sharedInflight.get(key);
|
||||||
|
if (running) return running;
|
||||||
|
const run = (async () => {
|
||||||
|
const entries = await getBulwarkMetadata(accountId);
|
||||||
|
// The user switched accounts meanwhile: these tags are another login's.
|
||||||
|
if (currentTagsLogin() !== login) return;
|
||||||
|
// A failed read (or none to make) keeps the last good tags.
|
||||||
|
if (!entries) return;
|
||||||
|
set((s) => ({
|
||||||
|
sharedTags: {
|
||||||
|
...s.sharedTags,
|
||||||
|
[accountId]: { login, tags: parseSharedTags(entries[SHARED_TAGS_KEY]), loadedAt: Date.now() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
})().finally(() => sharedInflight.delete(key));
|
||||||
|
sharedInflight.set(key, run);
|
||||||
|
return run;
|
||||||
|
},
|
||||||
|
|
||||||
hydrate: async () => {
|
hydrate: async () => {
|
||||||
if (get().hydrated) return;
|
if (get().hydrated) return;
|
||||||
@@ -97,3 +157,22 @@ export const useKeywordsStore = create<KeywordsState>((set, get) => ({
|
|||||||
persist(DEFAULT_KEYWORDS);
|
persist(DEFAULT_KEYWORDS);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tag definitions for messages of a JMAP account: a group account's
|
||||||
|
* shared tags first (read from its Inbox metadata, Stalwart 1.0), then the
|
||||||
|
* user's own; just the user's own for their own account or before the
|
||||||
|
* shared ones are known. `accountId` undefined means the user's own.
|
||||||
|
*/
|
||||||
|
export function useAccountKeywordDefs(accountId: string | undefined): KeywordDef[] {
|
||||||
|
const personal = useKeywordsStore((s) => s.keywords);
|
||||||
|
const entry = useKeywordsStore((s) => (accountId ? s.sharedTags[accountId] : undefined));
|
||||||
|
const loadSharedTags = useKeywordsStore((s) => s.loadSharedTags);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (accountId) void loadSharedTags(accountId);
|
||||||
|
}, [accountId, loadSharedTags]);
|
||||||
|
return React.useMemo(
|
||||||
|
() => (entry && entry.login === currentTagsLogin() ? mergeSharedTags(entry.tags, personal) : personal),
|
||||||
|
[entry, personal],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user