mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge branch 'feat/org-tags' into feat/org-features
# Conflicts: # lib/admin/restriction-keys.ts # lib/jmap/client.ts
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
|
||||
// Organisation > Sharing > Shared tags: one sentence and the lock for the
|
||||
// `sharedTags` restriction, written at the scope chosen in the scope bar.
|
||||
|
||||
const puts: Array<Record<string, unknown>> = [];
|
||||
let stored: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push(body);
|
||||
stored = body;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(stored));
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
getPathPrefix: () => '',
|
||||
}));
|
||||
|
||||
import { SharedTagsSection } from '../sections/shared-tags-section';
|
||||
import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
|
||||
import { RESTRICTION_KEYS } from '@/lib/admin/restriction-keys';
|
||||
import { resolvePolicy } from '@/lib/admin/policy-scopes';
|
||||
|
||||
const POLICY = {
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
scopes: [{ id: 'leads', label: 'Leads', match: { groups: ['leads@example.org'] }, policy: {} }],
|
||||
};
|
||||
|
||||
let latest: ReturnType<typeof useOrgPolicyDraft> | null = null;
|
||||
function Harness() {
|
||||
const draft = useOrgPolicyDraft('test');
|
||||
latest = draft;
|
||||
if (draft.loading) return null;
|
||||
return <SharedTagsSection draft={draft} />;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0;
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
latest = null;
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
|
||||
describe('Shared tags section', () => {
|
||||
it('registers sharedTags on the Sharing page, lock only', () => {
|
||||
expect(RESTRICTION_KEYS.find((def) => def.key === 'sharedTags')).toMatchObject({ page: 'org-sharing', modes: ['locked'] });
|
||||
});
|
||||
|
||||
it('locks shared tags for everyone and unlocks them in a group scope', async () => {
|
||||
render(<Harness />);
|
||||
const section = await screen.findByTestId('org-shared-tags-section');
|
||||
expect(within(section).queryByText('Hide')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(section).getByRole('checkbox'));
|
||||
expect(latest!.policy?.restrictions.sharedTags).toEqual({ locked: true });
|
||||
|
||||
act(() => latest!.setScopeId('leads'));
|
||||
await waitFor(() => expect(within(screen.getByTestId('org-shared-tags-section')).getByRole('checkbox')).toBeChecked());
|
||||
fireEvent.click(within(screen.getByTestId('org-shared-tags-section')).getByRole('checkbox'));
|
||||
// In a scope an empty restriction is a real override: it unlocks.
|
||||
expect(latest!.policy?.scopes?.[0].policy.restrictions?.sharedTags).toEqual({});
|
||||
const lead = resolvePolicy(latest!.policy!, { email: 'anna@example.org', groups: ['leads@example.org'] }).policy;
|
||||
const member = resolvePolicy(latest!.policy!, { email: 'bob@example.org', groups: ['team@example.org'] }).policy;
|
||||
expect(lead.restrictions.sharedTags?.locked).not.toBe(true);
|
||||
expect(member.restrictions.sharedTags?.locked).toBe(true);
|
||||
|
||||
await act(async () => { await latest!.save(); });
|
||||
expect(puts[0]).toMatchObject({
|
||||
restrictions: { sharedTags: { locked: true } },
|
||||
scopes: [{ id: 'leads', policy: { restrictions: { sharedTags: {} } } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { restrictionKeysForPage } from '@/lib/admin/restriction-keys';
|
||||
import { RestrictionRow } from '../org-page';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
/** Organisation feature 6; filled in by its own step (local-data/org-features-plan.md). */
|
||||
/**
|
||||
* Organisation feature 6: shared tags in group mailboxes. The tags live in
|
||||
* the group's Inbox on the mail server (Stalwart 1.0 metadata), so the only
|
||||
* thing to set here is who may change them.
|
||||
*/
|
||||
export function SharedTagsSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
void draft;
|
||||
return (
|
||||
<SettingsSection id="shared-tags" title="Shared tags" description="Tag names and colours that every member of a shared mailbox sees.">
|
||||
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
|
||||
<SettingsSection
|
||||
id="shared-tags"
|
||||
title="Shared tags"
|
||||
description="Members of a group mailbox see the same tag names and colors on its mail; members who may change the mailbox can edit them (Stalwart v1.0.0 or later)."
|
||||
>
|
||||
<div data-testid="org-shared-tags-section">
|
||||
{restrictionKeysForPage('org-sharing')
|
||||
.filter((def) => def.key === 'sharedTags')
|
||||
.map((def) => <RestrictionRow key={def.key} draft={draft} def={def} />)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({
|
||||
PluginSlot: () => null,
|
||||
}));
|
||||
|
||||
import { EmailContextMenu } from '../email-context-menu';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { loadSharedTags, resetSharedTags, sharedTagsKey, useSharedTagsStore } from '@/stores/shared-tags-store';
|
||||
|
||||
// The context menu's tag list for a selection offers the tags of the
|
||||
// selection's account (commonTagScope, like the toolbar), not those of the
|
||||
// row that was right-clicked.
|
||||
|
||||
const TEAM = { loginId: 'login-1', accountId: 'team' };
|
||||
|
||||
// A message in the group account, right-clicked.
|
||||
const email = {
|
||||
id: 'e1',
|
||||
threadId: 't1',
|
||||
mailboxIds: { 'team:inbox': true },
|
||||
keywords: { $seen: true },
|
||||
from: [{ email: 'alice@example.com' }],
|
||||
subject: 'Hello',
|
||||
receivedAt: '2026-01-01T00:00:00Z',
|
||||
size: 100,
|
||||
sourceClientAccountId: 'login-1',
|
||||
sourceAccountId: 'team',
|
||||
} as unknown as Email;
|
||||
|
||||
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
|
||||
|
||||
function openTags(batchTagScope: typeof TEAM | null) {
|
||||
render(
|
||||
<EmailContextMenu
|
||||
email={email}
|
||||
position={{ x: 10, y: 10 }}
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
menuRef={{ current: null }}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox="inbox"
|
||||
currentMailboxRole="inbox"
|
||||
isMultiSelect
|
||||
selectedCount={2}
|
||||
batchTagIds={[]}
|
||||
batchPartialTagIds={[]}
|
||||
batchTagScope={batchTagScope}
|
||||
onBatchToggleTag={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
act(() => { fireEvent.mouseEnter(screen.getByTestId('ctx-tag')); });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetSharedTags();
|
||||
// The group's tags come through the real loading path.
|
||||
const client = {
|
||||
getAccountId: () => 'me',
|
||||
getSieveAccountId: () => 'me',
|
||||
supportsSieve: () => false,
|
||||
getSharedAccounts: () => [],
|
||||
getAccountInfo: () => ({ isPersonal: false }),
|
||||
getPrincipalTypes: async () => ({ team: 'group' }),
|
||||
advertisesBulwarkMetadata: () => true,
|
||||
supportsBulwarkMetadata: () => true,
|
||||
getBulwarkMetadata: async () => ({ tags: { tags: { urgent: { label: 'Team urgent', color: 'red' } } } }),
|
||||
discoverKeywords: async () => ({ keywords: {}, scanned: 0, total: 0, complete: true }),
|
||||
};
|
||||
useAuthStore.setState({
|
||||
client: client as never,
|
||||
activeAccountId: 'login-1',
|
||||
getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never,
|
||||
});
|
||||
useSettingsStore.setState({ emailKeywords: [{ id: 'mine', label: 'Mine', color: 'blue' }], nestedTags: false });
|
||||
await loadSharedTags(sharedTagsKey(TEAM.loginId, TEAM.accountId), TEAM.accountId, client);
|
||||
expect(useSharedTagsStore.getState().entries[sharedTagsKey(TEAM.loginId, TEAM.accountId)]?.status).toBe('ready');
|
||||
});
|
||||
afterEach(() => resetSharedTags());
|
||||
|
||||
describe('EmailContextMenu tags for a selection', () => {
|
||||
it('offers the group\'s shared tags when the whole selection is in the group', async () => {
|
||||
openTags(TEAM);
|
||||
expect(await screen.findByText('Team urgent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mine')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the own tags when the selection mixes accounts, even from a group row', async () => {
|
||||
openTags(null);
|
||||
expect(await screen.findByText('Mine')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Team urgent')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tag } from "@/components/icons";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
import type { TagScope } from "@/hooks/use-account-tags";
|
||||
|
||||
const TAG_PICKER_WIDTH = 224;
|
||||
|
||||
@@ -22,6 +23,7 @@ export function BatchTagButton({
|
||||
onToggle,
|
||||
active,
|
||||
disabled,
|
||||
scope = null,
|
||||
}: {
|
||||
title: string;
|
||||
/** Tags every selected message carries. */
|
||||
@@ -32,6 +34,8 @@ export function BatchTagButton({
|
||||
/** Whether there is a selection to tag; the picker shuts without one. */
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
/** The account of the whole selection (commonTagScope); null: the user's own tags. */
|
||||
scope?: TagScope | null;
|
||||
}) {
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const pickerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -97,7 +101,7 @@ export function BatchTagButton({
|
||||
className="fixed z-50 w-56 max-w-[18rem] rounded-lg border border-border bg-popover py-1 shadow-lg"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
<TagPicker selectedIds={selectedIds} partialIds={partialIds} onToggle={onToggle} />
|
||||
<TagPicker selectedIds={selectedIds} partialIds={partialIds} onToggle={onToggle} scope={scope} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useEmailStore, removalFolder, canRemoveFromFolder, emailLinkSource } from "@/stores/email-store";
|
||||
import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags";
|
||||
import { Email, Mailbox } from "@/lib/jmap/types";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -112,6 +113,11 @@ interface EmailContextMenuProps {
|
||||
/** Tags every selected message carries, and those only some of them do. */
|
||||
batchTagIds?: string[];
|
||||
batchPartialTagIds?: string[];
|
||||
/**
|
||||
* The account of the whole selection (commonTagScope): its tags are
|
||||
* offered. Null when the selection mixes accounts: the user's own tags.
|
||||
*/
|
||||
batchTagScope?: TagScope | null;
|
||||
/** Takes the tag off when the whole selection has it, else puts it on all. */
|
||||
onBatchToggleTag?: (tagId: string) => void;
|
||||
onBatchDelete?: () => void;
|
||||
@@ -179,6 +185,7 @@ export function EmailContextMenu({
|
||||
onBatchMarkAsRead,
|
||||
batchTagIds,
|
||||
batchPartialTagIds,
|
||||
batchTagScope = null,
|
||||
onBatchToggleTag,
|
||||
onBatchDelete,
|
||||
onBatchArchive,
|
||||
@@ -206,6 +213,8 @@ export function EmailContextMenu({
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isDraft = email.keywords?.['$draft'] === true;
|
||||
const currentTagIds = getEmailTagIds(email.keywords);
|
||||
// The row's account names its tags; a selection uses batchTagScope.
|
||||
const tagScope = useEmailTagScope(email);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
// Marking your own outgoing mail as spam makes no sense - hide the action
|
||||
@@ -503,11 +512,13 @@ export function EmailContextMenu({
|
||||
selectedIds={batchTagIds ?? []}
|
||||
partialIds={batchPartialTagIds}
|
||||
onToggle={(tagId) => onBatchToggleTag?.(tagId)}
|
||||
scope={batchTagScope}
|
||||
/>
|
||||
) : (
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => onSetTag?.(tagId)}
|
||||
scope={tagScope}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useArchiveActionEnabled, withoutArchiveIfOff } from "@/lib/archive-gate
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { TagPicker } from "@/components/email/tag-picker";
|
||||
import { useEmailTagScope } from "@/hooks/use-account-tags";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
|
||||
@@ -111,6 +112,7 @@ export function EmailHoverActions({
|
||||
spamApplicable = true,
|
||||
mailboxRole = null,
|
||||
}: EmailHoverActionsProps) {
|
||||
const tagScope = useEmailTagScope(email);
|
||||
const configuredHoverActions = useSettingsStore((state) => state.hoverActions);
|
||||
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
|
||||
const archiveActionEnabled = useArchiveActionEnabled();
|
||||
@@ -294,6 +296,7 @@ export function EmailHoverActions({
|
||||
>
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
scope={tagScope}
|
||||
onToggle={(tagId) => onSetTag(tagId)}
|
||||
/>
|
||||
</div>,
|
||||
|
||||
@@ -44,6 +44,7 @@ import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
|
||||
import { unreadSectionRowCount } from "@/lib/unread-first";
|
||||
import { MvListSectionHeader } from "./mv-list-section-header";
|
||||
import { type OpenableEmail, PluginSearchResults } from "./plugin-search-results";
|
||||
import { commonTagScope } from "@/hooks/use-account-tags";
|
||||
|
||||
interface EmailListProps {
|
||||
emails: Email[];
|
||||
@@ -256,6 +257,7 @@ export function EmailList({
|
||||
const listHold = useEmailStore((state) => state.listHold);
|
||||
const viewingAccountId = useEmailStore((state) => state.viewingAccountId);
|
||||
const selectedKeyword = useEmailStore((state) => state.selectedKeyword);
|
||||
const selectedKeywordAccountId = useEmailStore((state) => state.selectedKeywordAccountId);
|
||||
const searchMailboxId = useEmailStore((state) => state.searchMailboxId);
|
||||
const activeAccountId = useAuthStore((state) => state.activeAccountId);
|
||||
const activeTabId = useMessageListTabsStore((state) => state.activeTabId);
|
||||
@@ -511,6 +513,7 @@ export function EmailList({
|
||||
viewingAccountId,
|
||||
selectedMailbox,
|
||||
selectedKeyword,
|
||||
selectedKeywordAccountId,
|
||||
isUnifiedView && (crossView ?? unifiedRole),
|
||||
isScheduledView,
|
||||
activeTabId,
|
||||
@@ -587,7 +590,8 @@ export function EmailList({
|
||||
const all: string[] = [];
|
||||
const some: string[] = [];
|
||||
for (const [tagId, count] of counts) (count === selected.length ? all : some).push(tagId);
|
||||
return { all, some };
|
||||
// A selection within one group account offers that account's shared tags.
|
||||
return { all, some, scope: commonTagScope(selected) };
|
||||
}, [emails, selectedEmailKeys]);
|
||||
|
||||
// A tag every selected message has comes off; any other goes onto all of
|
||||
@@ -860,6 +864,7 @@ export function EmailList({
|
||||
title={tContextMenu('tag')}
|
||||
selectedIds={selectionTags.all}
|
||||
partialIds={selectionTags.some}
|
||||
scope={selectionTags.scope}
|
||||
onToggle={handleBatchToggleTag}
|
||||
active={hasSelection && !isScheduledView}
|
||||
disabled={isProcessing}
|
||||
@@ -1182,6 +1187,7 @@ export function EmailList({
|
||||
onBatchMarkAsRead={(read) => markSelectionRead(read)}
|
||||
batchTagIds={selectionTags.all}
|
||||
batchPartialTagIds={selectionTags.some}
|
||||
batchTagScope={selectionTags.scope}
|
||||
onBatchToggleTag={handleBatchToggleTag}
|
||||
onBatchDelete={async () => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -30,6 +30,7 @@ import { TagBadge } from "./tag-badge";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useAccountTags, useEmailTagScope } from "@/hooks/use-account-tags";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
@@ -1120,8 +1121,10 @@ export function EmailViewer({
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
// A message in a group account is tagged with that account's shared tags.
|
||||
const tagScope = useEmailTagScope(email);
|
||||
const emailKeywords = useAccountTags(tagScope).definitions;
|
||||
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
|
||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||
const showToolbarLabelsSetting = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
|
||||
@@ -4110,6 +4113,7 @@ export function EmailViewer({
|
||||
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
scope={tagScope}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
</div>
|
||||
@@ -4404,6 +4408,7 @@ export function EmailViewer({
|
||||
>
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
scope={tagScope}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
</SubMenuPanel>
|
||||
@@ -4865,6 +4870,7 @@ export function EmailViewer({
|
||||
<TagPicker
|
||||
touch
|
||||
selectedIds={currentTagIds}
|
||||
scope={tagScope}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
)}
|
||||
@@ -4948,6 +4954,7 @@ export function EmailViewer({
|
||||
key={tagId}
|
||||
tagId={tagId}
|
||||
variant={headerTagVariant}
|
||||
scope={tagScope}
|
||||
onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { cn, type MailboxNode } from "@/lib/utils";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
|
||||
import { commonTagScope } from "@/hooks/use-account-tags";
|
||||
|
||||
interface MvListToolbarProps {
|
||||
/** Loaded conversation count, for the right-hand range readout. */
|
||||
@@ -643,7 +644,7 @@ export function MvListToolbar({
|
||||
aria-label={tMenu("tag")}
|
||||
className="absolute start-0 top-full mt-1 z-50 min-w-64 rounded-lg border border-border bg-popover py-1 shadow-xl"
|
||||
>
|
||||
<TagPicker selectedIds={appliedTags} partialIds={partialTags} onToggle={handleToggleTag} />
|
||||
<TagPicker selectedIds={appliedTags} partialIds={partialTags} onToggle={handleToggleTag} scope={commonTagScope(selectedEmails)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslations } from "next-intl";
|
||||
import { X } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import type { TagScope } from "@/hooks/use-account-tags";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
|
||||
/**
|
||||
@@ -42,6 +43,7 @@ export function TagBadge({
|
||||
variant,
|
||||
onRemove,
|
||||
className,
|
||||
scope,
|
||||
}: {
|
||||
tagId: string;
|
||||
variant: TagBadgeVariant;
|
||||
@@ -51,9 +53,11 @@ export function TagBadge({
|
||||
*/
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
/** The message's account (useEmailTagScope); undefined: TagScopeContext. */
|
||||
scope?: TagScope | null;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat();
|
||||
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat(scope);
|
||||
const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId));
|
||||
const color = tagColor(tagId);
|
||||
const name = tagName(tagId);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useAccountTags, type TagScope } from "@/hooks/use-account-tags";
|
||||
|
||||
/** Below this many tags a filter box costs more room than it saves. */
|
||||
const SEARCH_THRESHOLD = 10;
|
||||
@@ -30,6 +31,7 @@ export function TagPicker({
|
||||
partialIds = NO_IDS,
|
||||
onToggle,
|
||||
touch = false,
|
||||
scope,
|
||||
}: {
|
||||
selectedIds: string[];
|
||||
/**
|
||||
@@ -40,11 +42,18 @@ export function TagPicker({
|
||||
onToggle: (tagId: string) => void;
|
||||
/** Larger hit areas for the mobile sheet. */
|
||||
touch?: boolean;
|
||||
/**
|
||||
* The account whose tags to offer (a group account offers its shared
|
||||
* tags). Undefined: the TagScopeContext around the picker; null: the
|
||||
* user's own tags.
|
||||
*/
|
||||
scope?: TagScope | null;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const keywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const tShared = useTranslations("shared_tags");
|
||||
const { definitions: keywords, shared } = useAccountTags(scope);
|
||||
const nestedTags = useSettingsStore((state) => state.nestedTags);
|
||||
const { tagName, tagColor } = useKeywordFormat();
|
||||
const { tagName, tagColor } = useKeywordFormat(scope);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
@@ -119,6 +128,14 @@ export function TagPicker({
|
||||
|
||||
return (
|
||||
<>
|
||||
{shared && (
|
||||
<p
|
||||
className={cn("text-xs text-muted-foreground", touch ? "px-4 pb-2" : "px-3 pb-1")}
|
||||
data-testid="tag-picker-shared"
|
||||
>
|
||||
{tShared("picker_heading")}
|
||||
</p>
|
||||
)}
|
||||
{showSearch && (
|
||||
<div className={cn("relative", touch ? "px-3 pb-2" : "px-2 pb-1")}>
|
||||
<Search className="absolute start-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { emailKeyFor, getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useEmailTagScope } from "@/hooks/use-account-tags";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge } from "./tag-badge";
|
||||
import { useListRowShape, LIST_COLUMN_CLASS } from "@/hooks/use-list-row-shape";
|
||||
@@ -43,7 +44,8 @@ export function ThreadEmailItem({
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const { sortTagIds } = useKeywordFormat();
|
||||
const tagScope = useEmailTagScope(email);
|
||||
const { sortTagIds } = useKeywordFormat(tagScope);
|
||||
const { variant: tagVariant } = useTagDisplay();
|
||||
// A message inside an expanded thread carries its own tags; the collapsed
|
||||
// header pools them, so without this they disappear on the way in.
|
||||
@@ -164,7 +166,7 @@ export function ThreadEmailItem({
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
<span className={cn("min-w-0 truncate", isUnread ? "text-muted-foreground" : "text-muted-foreground/70")}>
|
||||
{cleanPreview(email.preview) || t('no_preview_available')}
|
||||
@@ -295,7 +297,7 @@ export function ThreadEmailItem({
|
||||
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useEmailStore, isUnscopedSearchActive } from "@/stores/email-store";
|
||||
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||
import { emailKeyFor, getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useEmailTagScope } from "@/hooks/use-account-tags";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
@@ -380,7 +381,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const formatAddress = useOwnDomainAddress();
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const tagScope = useEmailTagScope(email);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
|
||||
@@ -694,7 +696,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{tagIds.length > 0 && (
|
||||
<span className={cn(TAG_GROUP_CLASS, mvRow && "me-2")}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
@@ -775,7 +777,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{tagPlacement === 'sender' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
@@ -837,7 +839,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
@@ -1003,7 +1005,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
);
|
||||
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
|
||||
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const tagScope = useEmailTagScope(latestEmail);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
|
||||
@@ -1345,7 +1348,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
/>
|
||||
)}
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
@@ -1434,7 +1437,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
/>
|
||||
{tagPlacement === 'sender' && tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -1495,7 +1498,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// A group account's shared tags under its folders in the Shared section:
|
||||
// counted in that account only, and picking one lists that account's mail
|
||||
// with the tag, without selecting the user's own tag of the same id.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/contexts/drag-drop-context', () => ({
|
||||
useDragDropContext: () => ({ isDragging: false }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-mailbox-drop', () => ({
|
||||
useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-tag-drop', () => ({
|
||||
useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
|
||||
}));
|
||||
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
|
||||
|
||||
const { Sidebar } = await import('../sidebar');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { useEmailStore } = await import('@/stores/email-store');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
const { usePolicyStore } = await import('@/stores/policy-store');
|
||||
const { DEFAULT_POLICY } = await import('@/lib/admin/types');
|
||||
const { loadSharedTags, resetSharedTags, sharedTagsKey } = await import('@/stores/shared-tags-store');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true } },
|
||||
{ id: 'team:a', originalId: 'a', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, accountId: 'team', accountName: 'org-team@example.org', isShared: true },
|
||||
{ id: 'team:t', originalId: 't', name: 'Trash', role: 'trash', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, accountId: 'team', accountName: 'org-team@example.org', isShared: true },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const client = {
|
||||
getAccountId: () => 'me',
|
||||
getAccountInfo: () => ({ isPersonal: false }),
|
||||
getPrincipalTypes: async () => ({ team: 'group' }),
|
||||
advertisesBulwarkMetadata: () => true,
|
||||
supportsBulwarkMetadata: () => true,
|
||||
getBulwarkMetadata: async () => ({ tags: { tags: { urgent: { label: 'Team urgent', color: 'red' } } } }),
|
||||
discoverKeywords: async () => ({ keywords: {}, scanned: 0, total: 0, complete: true }),
|
||||
getTagCounts: vi.fn(async () => ({ urgent: { total: 5, unread: 2 } })),
|
||||
};
|
||||
|
||||
async function renderSidebar(onTagSelect = vi.fn()) {
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" selectedKeyword={useEmailStore.getState().selectedKeyword} onTagSelect={onTagSelect} />);
|
||||
const account = screen.getByTestId('section-shared-account');
|
||||
if (account.getAttribute('data-expanded') !== 'true') fireEvent.click(account);
|
||||
return within(await screen.findByTestId('shared-account-tags'));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('sidebarSharedExpanded', 'true');
|
||||
resetSharedTags();
|
||||
client.getTagCounts.mockClear();
|
||||
useAuthStore.setState({
|
||||
client: client as never,
|
||||
activeAccountId: 'login-1',
|
||||
getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never,
|
||||
getAllConnectedClients: (() => new Map([['login-1', client]])) as never,
|
||||
});
|
||||
useEmailStore.setState({ mailboxes, selectedKeyword: null, selectedKeywordAccountId: null, tagCounts: {} });
|
||||
useSettingsStore.setState({
|
||||
hideAccountSwitcher: true,
|
||||
enableUnifiedMailbox: false,
|
||||
showSharedMailAccounts: true,
|
||||
hiddenSharedMailAccounts: {},
|
||||
nestedTags: false,
|
||||
emailKeywords: [{ id: 'urgent', label: 'My urgent', color: 'blue' }],
|
||||
} as never);
|
||||
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY } });
|
||||
await loadSharedTags(sharedTagsKey('login-1', 'team'), 'team', client);
|
||||
});
|
||||
afterEach(() => resetSharedTags());
|
||||
|
||||
describe('Sidebar shared tags', () => {
|
||||
it('counts a shared tag in its group account only, outside Trash', async () => {
|
||||
const tags = await renderSidebar();
|
||||
expect(tags.getByText('Team urgent')).toBeInTheDocument();
|
||||
await waitFor(() => expect(client.getTagCounts).toHaveBeenCalledWith(['urgent'], 'team', ['t']));
|
||||
await waitFor(() => expect(tags.getByText('2')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('opens the tag view of that account and leaves the own tag of the same id unselected', async () => {
|
||||
const onTagSelect = vi.fn();
|
||||
const tags = await renderSidebar(onTagSelect);
|
||||
fireEvent.click(tags.getByText('Team urgent'));
|
||||
expect(onTagSelect).toHaveBeenCalledWith('urgent', 'team');
|
||||
|
||||
// The store now lists that account only; the sidebar marks the group's row.
|
||||
act(() => useEmailStore.getState().selectKeyword('urgent', 'team'));
|
||||
expect(useEmailStore.getState().selectedKeywordAccountId).toBe('team');
|
||||
cleanup();
|
||||
const after = await renderSidebar(onTagSelect);
|
||||
expect(after.getByText('Team urgent').closest('[data-testid="folder-row"]')).toHaveAttribute('data-selected', 'true');
|
||||
expect(screen.getByText('My urgent').closest('[data-testid="folder-row"]')).not.toHaveAttribute('data-selected');
|
||||
|
||||
// The own tag picked from the own list: the other way round.
|
||||
act(() => useEmailStore.getState().selectKeyword('urgent'));
|
||||
cleanup();
|
||||
const own = await renderSidebar(onTagSelect);
|
||||
expect(own.getByText('Team urgent').closest('[data-testid="folder-row"]')).not.toHaveAttribute('data-selected');
|
||||
expect(screen.getByText('My urgent').closest('[data-testid="folder-row"]')).toHaveAttribute('data-selected', 'true');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, useEffect, useMemo, Fragment, ReactNode } from "react";
|
||||
import { memo, useState, useEffect, useMemo, useCallback, Fragment, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "@/lib/keyword-nesting";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { TagScopeContext, useAccountTags } from "@/hooks/use-account-tags";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -70,7 +71,8 @@ import { unifiedLoadProgress } from "@/lib/unified-mailbox";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useEmailStore, buildTagViewAccountClients } from "@/stores/email-store";
|
||||
import { trashAndJunkIds } from "@/lib/unified-mailbox";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { AccountSwitcher } from "./account-switcher";
|
||||
@@ -96,7 +98,7 @@ interface SidebarProps {
|
||||
selectedMailbox?: string;
|
||||
selectedKeyword?: string | null;
|
||||
onMailboxSelect?: (mailboxId: string) => void;
|
||||
onTagSelect?: (keywordId: string | null) => void;
|
||||
onTagSelect?: (keywordId: string | null, accountId?: string | null) => void;
|
||||
onCompose?: () => void;
|
||||
/**
|
||||
* Mountain View, desktop only: the rail grows a Compose pill at the top and
|
||||
@@ -743,7 +745,7 @@ function TagItem({
|
||||
selectedKeyword: string | null;
|
||||
expandedTags: Set<string>;
|
||||
isCollapsed: boolean;
|
||||
onTagSelect?: (keywordId: string | null) => void;
|
||||
onTagSelect?: (keywordId: string | null, accountId?: string | null) => void;
|
||||
onToggleExpand: (keywordId: string) => void;
|
||||
tagCounts: Record<string, { total: number; unread: number }>;
|
||||
colorful: boolean;
|
||||
@@ -822,6 +824,106 @@ function TagItem({
|
||||
);
|
||||
}
|
||||
|
||||
type TagCounts = Record<string, { total: number; unread: number }>;
|
||||
const NO_TAG_COUNTS: TagCounts = {};
|
||||
|
||||
/**
|
||||
* Counts of a group account's shared tags in that account only, outside
|
||||
* Trash and Junk like the tag view. Asked again whenever the sidebar's own
|
||||
* tag counts are (every mail change), and when the tags change.
|
||||
*/
|
||||
function useSharedTagCounts(accountId: string, ids: string[], enabled: boolean): TagCounts {
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const refresh = useEmailStore((s) => s.tagCounts);
|
||||
const idsKey = ids.join('\n');
|
||||
const [counts, setCounts] = useState<TagCounts>(NO_TAG_COUNTS);
|
||||
useEffect(() => {
|
||||
if (!enabled || !client || !idsKey) return;
|
||||
const account = buildTagViewAccountClients(client).find((a) => a.jmapAccountId === accountId);
|
||||
if (!account) return;
|
||||
let cancelled = false;
|
||||
client.getTagCounts(idsKey.split('\n'), accountId, trashAndJunkIds(account))
|
||||
.then((next) => { if (!cancelled) setCounts(next); })
|
||||
.catch(() => { /* the rows show no count */ });
|
||||
return () => { cancelled = true; };
|
||||
}, [client, accountId, idsKey, refresh, enabled]);
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags of a group account, under its folders in the Shared section: the
|
||||
* shared definitions plus the tags found on its messages (useAccountTags),
|
||||
* with their counts in that account. Picking one lists that account's
|
||||
* messages with the tag. Nothing for a mailbox a person shared or a server
|
||||
* without metadata, where the user's own tags above stand for every account
|
||||
* as before.
|
||||
*/
|
||||
function SharedAccountTags({
|
||||
loginId,
|
||||
accountId,
|
||||
selectedKeyword,
|
||||
expandedTags,
|
||||
onTagSelect,
|
||||
onToggleExpand,
|
||||
colorful,
|
||||
}: {
|
||||
loginId: string;
|
||||
accountId: string;
|
||||
/** The tag picked in this account's list, if any. */
|
||||
selectedKeyword: string | null;
|
||||
expandedTags: Set<string>;
|
||||
onTagSelect?: (keywordId: string | null, accountId?: string | null) => void;
|
||||
onToggleExpand: (keywordId: string) => void;
|
||||
colorful: boolean;
|
||||
}) {
|
||||
const t = useTranslations('shared_tags');
|
||||
const router = useRouter();
|
||||
const scope = useMemo(() => ({ loginId, accountId }), [loginId, accountId]);
|
||||
const { definitions, shared } = useAccountTags(scope);
|
||||
const nestedTags = useSettingsStore((s) => s.nestedTags);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const tree = useMemo(
|
||||
() => (nestedTags ? buildKeywordTree(definitions) : definitions.map((kw) => ({ ...kw, children: [], depth: 0 }))),
|
||||
[definitions, nestedTags],
|
||||
);
|
||||
const ids = useMemo(() => definitions.map((kw) => kw.id), [definitions]);
|
||||
const counts = useSharedTagCounts(accountId, ids, shared && expanded);
|
||||
const select = useCallback(
|
||||
(keywordId: string | null) => onTagSelect?.(keywordId, keywordId ? accountId : null),
|
||||
[onTagSelect, accountId],
|
||||
);
|
||||
if (!shared || definitions.length === 0) return null;
|
||||
return (
|
||||
<TagScopeContext.Provider value={scope}>
|
||||
<div data-testid="shared-account-tags">
|
||||
<SidebarSectionHeader
|
||||
label={t('sidebar_heading')}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded((open) => !open)}
|
||||
onSettings={() => router.push(buildSettingsPath('organize', 'shared-tags'))}
|
||||
settingsTitle={t('edit')}
|
||||
testId="section-shared-tags"
|
||||
isCollapsed={false}
|
||||
sub
|
||||
/>
|
||||
{expanded && tree.map((node) => (
|
||||
<TagItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedKeyword={selectedKeyword}
|
||||
expandedTags={expandedTags}
|
||||
isCollapsed={false}
|
||||
onTagSelect={select}
|
||||
onToggleExpand={onToggleExpand}
|
||||
tagCounts={counts}
|
||||
colorful={colorful}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TagScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function DemoBanner() {
|
||||
const t = useTranslations('sidebar');
|
||||
const { isDemoMode, loginDemo } = useAuthStore(useShallow((state) => ({ isDemoMode: state.isDemoMode, loginDemo: state.loginDemo })));
|
||||
@@ -1013,6 +1115,9 @@ function SidebarImpl({
|
||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||
const nestedTags = useSettingsStore(s => s.nestedTags);
|
||||
const tagsAboveShared = usePolicyStore(s => s.isFeatureEnabled('tagsAboveSharedEnabled'));
|
||||
// A tag picked in a group account's shared tags (see SharedAccountTags).
|
||||
const selectedKeywordAccountId = useEmailStore(s => s.selectedKeywordAccountId);
|
||||
const personalSelectedKeyword = selectedKeywordAccountId ? null : selectedKeyword;
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
|
||||
// own AccountSwitcher would be a redundant second account UI in the same
|
||||
@@ -1174,7 +1279,7 @@ function SidebarImpl({
|
||||
// filling it back in. A tag the server answered for with zero unread hides,
|
||||
// which is the point of the setting.
|
||||
const isTagVisible = (node: KeywordNode) => {
|
||||
if (showAllTags || node.id === selectedKeyword) return true;
|
||||
if (showAllTags || node.id === personalSelectedKeyword) return true;
|
||||
const visibility = getKeywordVisibility(node);
|
||||
if (visibility === 'hide') return false;
|
||||
if (visibility === 'unread') {
|
||||
@@ -1312,7 +1417,7 @@ function SidebarImpl({
|
||||
<TagItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedKeyword={selectedKeyword}
|
||||
selectedKeyword={personalSelectedKeyword}
|
||||
expandedTags={expandedTags}
|
||||
isCollapsed={isCollapsed}
|
||||
onTagSelect={onTagSelect}
|
||||
@@ -1882,6 +1987,17 @@ function SidebarImpl({
|
||||
))}
|
||||
{menuAccountId && !account.children.some((child) => child.role === 'drafts')
|
||||
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })}
|
||||
{menuAccountId && activeAccountId && (
|
||||
<SharedAccountTags
|
||||
loginId={activeAccountId}
|
||||
accountId={menuAccountId}
|
||||
selectedKeyword={selectedKeywordAccountId === menuAccountId ? selectedKeyword : null}
|
||||
expandedTags={expandedTags}
|
||||
onTagSelect={onTagSelect}
|
||||
onToggleExpand={handleToggleTagExpand}
|
||||
colorful={colorfulSidebarIcons}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3311,9 +3311,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagSelect = async (keywordId: string | null) => {
|
||||
const handleTagSelect = async (keywordId: string | null, accountId: string | null = null) => {
|
||||
setScheduledView(false);
|
||||
selectKeyword(keywordId);
|
||||
selectKeyword(keywordId, accountId);
|
||||
|
||||
// On mobile, close sidebar and go to list view
|
||||
if (isMobile) {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Names with their tag in them: the global mock returns the bare key.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: { name?: string }) => (params?.name ? `${key}:${params.name}` : key),
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import { SharedTagSettings, maySetSharedTags } from '../shared-tag-settings';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { resetSharedTags, sharedTagsKey, useSharedTagsStore } from '@/stores/shared-tags-store';
|
||||
|
||||
// Settings > Folders & tags > Shared tags: the editor of each group
|
||||
// mailbox's tags, read-only when the admin locked them, and a note instead of
|
||||
// an editor where the server keeps no metadata or couldn't be read.
|
||||
|
||||
const URGENT = { label: 'Urgent', color: 'red' };
|
||||
const RIGHTS = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
|
||||
|
||||
let stored: Record<string, unknown> | null;
|
||||
let keywords: Record<string, number>;
|
||||
let advertised: boolean;
|
||||
let principalType: string;
|
||||
const client = {
|
||||
getAccountId: () => 'me',
|
||||
getAccountInfo: (id: string) => ({ isPersonal: id === 'me' }),
|
||||
getSharedAccounts: () => [
|
||||
{ id: 'me', name: 'org-alice@example.org', isPrimary: true, capabilities: { mail: true } },
|
||||
{ id: 'team', name: 'org-team@example.org', isPrimary: false, capabilities: { mail: true } },
|
||||
],
|
||||
getPrincipalTypes: async () => ({ team: principalType }),
|
||||
advertisesBulwarkMetadata: () => advertised,
|
||||
supportsBulwarkMetadata: () => advertised,
|
||||
getBulwarkMetadata: vi.fn(async () => (stored ? { tags: stored } : null)),
|
||||
patchBulwarkMetadata: vi.fn(async (_id: string | undefined, _key: string, patches: Array<{ path: string[]; value: unknown }>) => {
|
||||
const tags = { ...((stored?.tags ?? {}) as Record<string, unknown>) };
|
||||
for (const { path, value } of patches) {
|
||||
if (value === null) delete tags[path[1]];
|
||||
else tags[path[1]] = value;
|
||||
}
|
||||
stored = { tags };
|
||||
return { newState: 'sX' };
|
||||
}),
|
||||
discoverKeywords: async () => ({ keywords, scanned: 0, total: 0, complete: true }),
|
||||
getMailboxes: vi.fn(async () => []),
|
||||
};
|
||||
|
||||
function setRights(rights: Record<string, boolean>) {
|
||||
useEmailStore.setState({ mailboxes: [{ id: 'team:a', name: 'Inbox', role: 'inbox', accountId: 'team', isShared: true, myRights: rights } as never] });
|
||||
}
|
||||
|
||||
function setLocked(locked: boolean) {
|
||||
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, restrictions: locked ? { sharedTags: { locked: true } } : {} } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetSharedTags();
|
||||
stored = { tags: { urgent: { ...URGENT } } };
|
||||
keywords = {};
|
||||
advertised = true;
|
||||
principalType = 'group';
|
||||
client.getBulwarkMetadata.mockClear();
|
||||
client.patchBulwarkMetadata.mockClear();
|
||||
client.getMailboxes.mockClear();
|
||||
useSettingsStore.setState({ emailKeywords: [], nestedTags: false });
|
||||
useAuthStore.setState({
|
||||
client: client as never,
|
||||
activeAccountId: 'login-1',
|
||||
getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never,
|
||||
});
|
||||
setLocked(false);
|
||||
setRights({ ...RIGHTS, maySetMetadata: true });
|
||||
});
|
||||
afterEach(() => resetSharedTags());
|
||||
|
||||
describe('SharedTagSettings', () => {
|
||||
it('lets a member who may change the Inbox add a shared tag, as one patch', async () => {
|
||||
render(<SharedTagSettings />);
|
||||
expect(await screen.findByText('Urgent')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('add'));
|
||||
fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Waiting' } });
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [
|
||||
{ path: ['tags', 'waiting'], value: { label: 'Waiting', color: 'blue' } },
|
||||
]));
|
||||
expect(await screen.findByText('Waiting')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the buttons after their tag and puts focus back on the edit button after saving', async () => {
|
||||
render(<SharedTagSettings />);
|
||||
fireEvent.click(await screen.findByLabelText('edit_tag:Urgent'));
|
||||
fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Very urgent' } });
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
// The color wasn't picked, so it isn't sent: whatever is stored stays.
|
||||
await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [
|
||||
{ path: ['tags', 'urgent'], value: { label: 'Very urgent', color: 'red' } },
|
||||
]));
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('edit_tag:Very urgent')));
|
||||
});
|
||||
|
||||
it('warns that deleting is for everyone, in words, before it deletes', async () => {
|
||||
render(<SharedTagSettings />);
|
||||
fireEvent.click(await screen.findByLabelText('delete_tag:Urgent'));
|
||||
const confirm = screen.getByTestId('shared-tag-delete-confirm');
|
||||
const warning = screen.getByText('delete_confirm');
|
||||
expect(confirm).toContainElement(warning);
|
||||
// Shown at every width, not only from the sm breakpoint up.
|
||||
expect(warning.className).not.toMatch(/(^|\s)hidden(\s|$)/);
|
||||
fireEvent.click(screen.getByText('delete'));
|
||||
await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [{ path: ['tags', 'urgent'], value: null }]));
|
||||
});
|
||||
|
||||
it('shows the tags read-only with "Set by your organisation" when the admin locked them', async () => {
|
||||
setLocked(true);
|
||||
keywords = { '$label:found': 1 };
|
||||
const { container } = render(<SharedTagSettings />);
|
||||
expect(await screen.findByText('Urgent')).toBeInTheDocument();
|
||||
await screen.findByText('found_on_messages');
|
||||
expect(container.querySelector('[data-org-locked]')).not.toBeNull();
|
||||
expect(screen.queryByText('add')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/^edit_tag/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/^delete_tag/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/^name_it/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is read-only with a note for a member without the right to change the Inbox', async () => {
|
||||
setRights({ ...RIGHTS, maySetMetadata: false });
|
||||
render(<SharedTagSettings />);
|
||||
expect(await screen.findByTestId('shared-tags-no-rights')).toHaveTextContent('no_rights');
|
||||
expect(screen.queryByText('add')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('asks for no rights of a mailbox a person shared', async () => {
|
||||
principalType = 'individual';
|
||||
useEmailStore.setState({ mailboxes: [] });
|
||||
render(<SharedTagSettings />);
|
||||
await waitFor(() => expect(useSharedTagsStore.getState().entries[sharedTagsKey('login-1', 'team')]?.status).toBe('personal'));
|
||||
expect(client.getMailboxes).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId('shared-tags-account')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says in one line that the server keeps no shared tags, with no editor', async () => {
|
||||
advertised = false;
|
||||
render(<SharedTagSettings />);
|
||||
expect(await screen.findByTestId('shared-tags-unsupported')).toHaveTextContent('unsupported');
|
||||
expect(screen.queryByText('add')).not.toBeInTheDocument();
|
||||
expect(client.getBulwarkMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says so in one line when the shared tags couldn\'t be read', async () => {
|
||||
stored = null;
|
||||
render(<SharedTagSettings />);
|
||||
expect(await screen.findByTestId('shared-tags-unavailable')).toHaveTextContent('unavailable');
|
||||
expect(screen.queryByText('add')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a change the server didn\'t take and says it will try again', async () => {
|
||||
render(<SharedTagSettings />);
|
||||
await screen.findByText('Urgent');
|
||||
client.patchBulwarkMetadata.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
fireEvent.click(screen.getByText('add'));
|
||||
fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Waiting' } });
|
||||
await act(async () => { fireEvent.click(screen.getByText('save')); });
|
||||
expect(await screen.findByTestId('shared-tags-pending')).toHaveTextContent('save_pending');
|
||||
expect(screen.getByText('Waiting')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers to name a tag found on the messages, keeping its id', async () => {
|
||||
keywords = { '$label:waiting-for-customer': 2 };
|
||||
render(<SharedTagSettings />);
|
||||
fireEvent.click(await screen.findByLabelText('name_it:waiting-for-customer'));
|
||||
fireEvent.change(screen.getByLabelText('name'), { target: { value: 'Customer' } });
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
await waitFor(() => expect(client.patchBulwarkMetadata).toHaveBeenCalledWith('team', 'tags', [
|
||||
{ path: ['tags', 'waiting-for-customer'], value: { label: 'Customer', color: expect.any(String) } },
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('maySetSharedTags', () => {
|
||||
it('follows maySetMetadata, and renaming where the server has no such right', () => {
|
||||
expect(maySetSharedTags({ maySetMetadata: true, mayRename: false })).toBe(true);
|
||||
expect(maySetSharedTags({ maySetMetadata: false, mayRename: true })).toBe(false);
|
||||
expect(maySetSharedTags({ mayRename: true })).toBe(true);
|
||||
expect(maySetSharedTags(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
import { TagBadge } from "@/components/email/tag-badge";
|
||||
|
||||
/** Lighter, base and darker shade of each hue, one row per shade. */
|
||||
function KeywordColorPicker({
|
||||
export function KeywordColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { FolderSettings } from './folder-settings';
|
||||
import { KeywordSettings } from './keyword-settings';
|
||||
import { SharedTagSettings } from './shared-tag-settings';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
|
||||
/**
|
||||
@@ -14,6 +15,7 @@ export function OrganizeSettings() {
|
||||
<div className="space-y-8">
|
||||
<FolderSettings />
|
||||
{tagsEnabled && <KeywordSettings />}
|
||||
{tagsEnabled && <SharedTagSettings />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, Loader2, Pencil, Plus, Trash2, Users, X } from "@/components/icons";
|
||||
import { TagBadge } from "@/components/email/tag-badge";
|
||||
import { useAccountTags, TagScopeContext, clientForLogin, type TagScope } from "@/hooks/use-account-tags";
|
||||
import { useRestriction } from "@/hooks/use-restriction";
|
||||
import { MetadataTooLargeError } from "@/lib/jmap/bulwark-metadata";
|
||||
import type { MailboxRights } from "@/lib/jmap/types";
|
||||
import { composeKeywordId, MAX_KEYWORD_ID_LENGTH } from "@/lib/keyword-nesting";
|
||||
import { MAX_SHARED_TAG_LABEL, groupAccountTags, type SharedTagChange } from "@/lib/shared-tags";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { KEYWORD_PALETTE, useSettingsStore, type KeywordDefinition } from "@/stores/settings-store";
|
||||
import { SharedTagsSaveDeferredError, saveSharedTags, sharedTagsKey, useSharedTagsStore } from "@/stores/shared-tags-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
import { KeywordColorPicker } from "./keyword-settings";
|
||||
import { OrgLockedNote } from "./org-locked-note";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
/** Whether the rights on a group's Inbox let the user change its metadata. */
|
||||
export function maySetSharedTags(rights: Partial<MailboxRights> | null | undefined): boolean {
|
||||
if (!rights) return false;
|
||||
// Stalwart 1.0 names the right; a server that doesn't counts renaming.
|
||||
return typeof rights.maySetMetadata === "boolean" ? rights.maySetMetadata : rights.mayRename === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's rights on the Inbox of a confirmed group account, from the
|
||||
* folder list or asked once. Nothing is asked for any other account.
|
||||
*/
|
||||
function useInboxRights(scope: TagScope, enabled: boolean): Partial<MailboxRights> | null | undefined {
|
||||
const fromStore = useEmailStore((state) =>
|
||||
state.mailboxes.find((mailbox) => mailbox.accountId === scope.accountId && mailbox.role === "inbox")?.myRights);
|
||||
const [fetched, setFetched] = useState<Partial<MailboxRights> | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!enabled || fromStore) return;
|
||||
let cancelled = false;
|
||||
const client = clientForLogin(scope.loginId) as unknown as { getMailboxes?: (accountId?: string) => Promise<Array<{ role?: string; myRights?: MailboxRights }>> } | null;
|
||||
if (!client?.getMailboxes) return;
|
||||
client.getMailboxes(scope.accountId)
|
||||
.then((mailboxes) => { if (!cancelled) setFetched(mailboxes.find((m) => m.role === "inbox")?.myRights ?? null); })
|
||||
.catch(() => { if (!cancelled) setFetched(null); });
|
||||
return () => { cancelled = true; };
|
||||
}, [enabled, fromStore, scope.loginId, scope.accountId]);
|
||||
return enabled ? fromStore ?? fetched : undefined;
|
||||
}
|
||||
|
||||
function SharedTagForm({
|
||||
initial,
|
||||
fixedId,
|
||||
takenIds,
|
||||
saving,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: KeywordDefinition;
|
||||
/** The id stays: an existing tag or one found on messages. */
|
||||
fixedId?: string;
|
||||
takenIds: string[];
|
||||
saving: boolean;
|
||||
/** `color` only when it was picked (or the tag is new), so a color this version doesn't know survives a rename. */
|
||||
onSave: (id: string, value: { label: string; color?: string }) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const t = useTranslations("shared_tags");
|
||||
const [label, setLabel] = useState(initial?.label ?? "");
|
||||
const [color, setColor] = useState(initial?.color ?? "blue");
|
||||
const [colorPicked, setColorPicked] = useState(false);
|
||||
const id = fixedId ?? composeKeywordId(null, label);
|
||||
const duplicate = !fixedId && id.length > 0 && takenIds.includes(id.toLowerCase());
|
||||
const tooLong = !fixedId && id.length > MAX_KEYWORD_ID_LENGTH;
|
||||
const valid = label.trim().length > 0 && id.length > 0 && !duplicate && !tooLong;
|
||||
const keepsColor = !!initial && !colorPicked;
|
||||
const save = () => {
|
||||
if (valid && !saving) onSave(id, keepsColor ? { label: label.trim() } : { label: label.trim(), color });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-primary/30 bg-accent/30 p-3" data-testid="shared-tag-form">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground" htmlFor={`shared-tag-label-${fixedId ?? "new"}`}>
|
||||
{t("name")}
|
||||
</label>
|
||||
<input
|
||||
id={`shared-tag-label-${fixedId ?? "new"}`}
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
maxLength={MAX_SHARED_TAG_LABEL}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") save();
|
||||
if (e.key === "Escape") { e.stopPropagation(); onCancel(); }
|
||||
}}
|
||||
className="w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
{duplicate && <p className="mt-1 text-xs text-destructive">{t("exists")}</p>}
|
||||
{tooLong && <p className="mt-1 text-xs text-destructive">{t("too_long")}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-medium text-muted-foreground">{t("color")}</p>
|
||||
<KeywordColorPicker value={color} onChange={(next) => { setColor(next); setColorPicked(true); }} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex min-h-[44px] items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted sm:min-h-0"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!valid || saving}
|
||||
className="flex min-h-[44px] items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 sm:min-h-0"
|
||||
>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||
{t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ICON_BUTTON = "grid h-11 w-11 place-items-center rounded-md text-muted-foreground hover:bg-muted sm:h-8 sm:w-8";
|
||||
|
||||
/**
|
||||
* The shared tags of one group account. Read-only with "Set by your
|
||||
* organisation" when the admin locked `sharedTags`, read-only with a note
|
||||
* when the user may not change the group's Inbox, and a one-line note
|
||||
* instead of the editor when the server keeps no metadata or couldn't be
|
||||
* read.
|
||||
*/
|
||||
function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string }) {
|
||||
const t = useTranslations("shared_tags");
|
||||
const key = sharedTagsKey(scope.loginId, scope.accountId);
|
||||
const { status } = useAccountTags(scope);
|
||||
const entry = useSharedTagsStore((state) => state.entries[key]);
|
||||
const nested = useSettingsStore((state) => state.nestedTags);
|
||||
const { locked } = useRestriction("sharedTags");
|
||||
const rights = useInboxRights(scope, status === "ready");
|
||||
const canWrite = maySetSharedTags(rights);
|
||||
const editable = !locked && canWrite;
|
||||
const [editing, setEditing] = useState<{ id?: string } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [focusId, setFocusId] = useState<string | null>(null);
|
||||
const editButtons = useRef(new Map<string, HTMLButtonElement>());
|
||||
|
||||
const { shared, discovered } = useMemo(() => {
|
||||
if (status !== "ready" || !entry) return { shared: [] as KeywordDefinition[], discovered: [] as KeywordDefinition[] };
|
||||
const { definitions, discoveredIds } = groupAccountTags(entry.tags, entry.discovered, nested);
|
||||
const found = new Set(discoveredIds);
|
||||
return { shared: entry.tags, discovered: definitions.filter((tag) => found.has(tag.id)) };
|
||||
}, [status, entry, nested]);
|
||||
const takenIds = useMemo(() => [...shared, ...discovered].map((tag) => tag.id.toLowerCase()), [shared, discovered]);
|
||||
|
||||
// Back to the row's edit button once a change is saved.
|
||||
useEffect(() => {
|
||||
if (!focusId) return;
|
||||
editButtons.current.get(focusId)?.focus();
|
||||
setFocusId(null);
|
||||
}, [focusId, shared]);
|
||||
|
||||
if (status === "personal") return null;
|
||||
|
||||
const save = async (change: SharedTagChange) => {
|
||||
const client = clientForLogin(scope.loginId);
|
||||
if (!client) return false;
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSharedTags(key, scope.accountId, client, [change]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof SharedTagsSaveDeferredError) {
|
||||
// Kept and shown; sent again when the server answers.
|
||||
toast.warning(t("save_failed"));
|
||||
return true;
|
||||
}
|
||||
toast.error(error instanceof MetadataTooLargeError ? t("too_many") : t("save_failed"));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const heading = (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Users className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="truncate text-sm font-medium text-foreground">{toUnicodeEmail(name)}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="space-y-2" data-testid="shared-tags-account" data-status="loading">
|
||||
{heading}
|
||||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t("loading")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unsupported" || status === "unavailable") {
|
||||
return (
|
||||
<div className="space-y-2" data-testid="shared-tags-account" data-status={status}>
|
||||
{heading}
|
||||
<p className="text-xs text-muted-foreground" data-testid={`shared-tags-${status}`}>{t(status)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const row = (tag: KeywordDefinition, found: boolean) => {
|
||||
if (editing && editing.id === tag.id) {
|
||||
const raw = entry?.stored?.entries[tag.id];
|
||||
// The form shows the stored color when this version knows it.
|
||||
const initial = { ...tag, color: typeof raw?.color === "string" && KEYWORD_PALETTE[raw.color] ? raw.color : tag.color };
|
||||
return (
|
||||
<SharedTagForm
|
||||
key={tag.id}
|
||||
initial={initial}
|
||||
fixedId={tag.id}
|
||||
takenIds={takenIds}
|
||||
saving={saving}
|
||||
onCancel={() => { setEditing(null); setFocusId(tag.id); }}
|
||||
onSave={async (id, value) => {
|
||||
const next = found ? { label: value.label, color: value.color ?? tag.color } : value;
|
||||
if (await save({ id, value: next })) { setEditing(null); setFocusId(id); }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const confirming = editable && confirmDelete === tag.id;
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex min-h-[44px] flex-wrap items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 sm:min-h-0 sm:flex-nowrap"
|
||||
data-testid="shared-tag-row"
|
||||
data-tag-id={tag.id}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<TagBadge tagId={tag.id} variant="badge" className="text-xs" />
|
||||
{found && <span className="truncate text-xs text-muted-foreground">{t("found_on_messages")}</span>}
|
||||
</div>
|
||||
{confirming ? (
|
||||
<div className="flex basis-full flex-wrap items-center justify-end gap-1 sm:basis-auto sm:flex-nowrap" data-testid="shared-tag-delete-confirm">
|
||||
<span className="text-xs text-muted-foreground">{t("delete_confirm")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => { if (await save({ id: tag.id, value: null })) setConfirmDelete(null); }}
|
||||
className="min-h-[44px] rounded-md px-3 py-1 text-xs text-destructive hover:bg-destructive/10 sm:min-h-0"
|
||||
>
|
||||
{t("delete")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setConfirmDelete(null); setFocusId(tag.id); }}
|
||||
className="min-h-[44px] rounded-md px-3 py-1 text-xs hover:bg-muted sm:min-h-0"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
) : editable && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
ref={(el) => { if (el) editButtons.current.set(tag.id, el); else editButtons.current.delete(tag.id); }}
|
||||
onClick={() => { setEditing({ id: tag.id }); setConfirmDelete(null); }}
|
||||
className={cn(ICON_BUTTON, "hover:text-foreground")}
|
||||
title={found ? t("name_it", { name: tag.id }) : t("edit_tag", { name: tag.label })}
|
||||
aria-label={found ? t("name_it", { name: tag.id }) : t("edit_tag", { name: tag.label })}
|
||||
>
|
||||
{found ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
||||
</button>
|
||||
{!found && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setConfirmDelete(tag.id); setEditing(null); }}
|
||||
className={cn(ICON_BUTTON, "hover:text-destructive")}
|
||||
title={t("delete_tag", { name: tag.label })}
|
||||
aria-label={t("delete_tag", { name: tag.label })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TagScopeContext.Provider value={scope}>
|
||||
<div className="space-y-2" data-testid="shared-tags-account" data-status="ready" data-editable={editable || undefined}>
|
||||
{heading}
|
||||
{shared.length === 0 && discovered.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t("empty")}</p>
|
||||
)}
|
||||
{shared.map((tag) => row(tag, false))}
|
||||
{discovered.map((tag) => row(tag, true))}
|
||||
{(entry?.pending.length ?? 0) > 0 && (
|
||||
<p className="text-xs text-muted-foreground" data-testid="shared-tags-pending">{t("save_pending")}</p>
|
||||
)}
|
||||
{locked ? (
|
||||
<OrgLockedNote />
|
||||
) : !canWrite ? (
|
||||
<p className="text-xs text-muted-foreground" data-testid="shared-tags-no-rights">
|
||||
{rights === undefined ? t("loading") : t("no_rights")}
|
||||
</p>
|
||||
) : editing && !editing.id ? (
|
||||
<SharedTagForm
|
||||
takenIds={takenIds}
|
||||
saving={saving}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSave={async (id, value) => { if (await save({ id, value })) { setEditing(null); setFocusId(id); } }}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditing({}); setConfirmDelete(null); }}
|
||||
className={cn(
|
||||
"flex min-h-[44px] items-center gap-1.5 rounded-md border border-dashed border-border px-3 py-1.5 text-xs text-muted-foreground transition-colors sm:min-h-0",
|
||||
"hover:border-primary hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TagScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings > Folders & tags > Shared tags: the tags of each group mailbox
|
||||
* the user is a member of. Hidden without group accounts. The account list
|
||||
* is worked out again whenever the folder list changes, which it does after
|
||||
* every session change.
|
||||
*/
|
||||
export function SharedTagSettings() {
|
||||
const t = useTranslations("shared_tags");
|
||||
const client = useAuthStore((state) => state.client);
|
||||
const loginId = useAuthStore((state) => state.activeAccountId);
|
||||
const mailboxes = useEmailStore((state) => state.mailboxes);
|
||||
const accounts = useMemo(
|
||||
() => {
|
||||
void mailboxes;
|
||||
return client && typeof client.getSharedAccounts === "function"
|
||||
? client.getSharedAccounts().filter((account) => !account.isPrimary && account.capabilities.mail && client.getAccountInfo?.(account.id)?.isPersonal === false)
|
||||
: [];
|
||||
},
|
||||
[client, mailboxes],
|
||||
);
|
||||
const entries = useSharedTagsStore((state) => state.entries);
|
||||
if (!loginId || accounts.length === 0) return null;
|
||||
// Sharing by a person isn't a group: shown only once known not to be one.
|
||||
const groups = accounts.filter((account) => entries[sharedTagsKey(loginId, account.id)]?.status !== "personal");
|
||||
return (
|
||||
<div className={groups.length === 0 ? "hidden" : undefined}>
|
||||
<SettingsSection id="shared-tags" title={t("title")} description={t("description")}>
|
||||
<div className="space-y-5" data-testid="shared-tag-settings">
|
||||
{accounts.map((account) => (
|
||||
<SharedAccountTagEditor key={account.id} scope={{ loginId, accountId: account.id }} name={account.name} />
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { TagBadge } from '@/components/email/tag-badge';
|
||||
import { TagPicker } from '@/components/email/tag-picker';
|
||||
import { TagScopeContext, tagScopeOf, type TagScope } from '../use-account-tags';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { KEYWORD_PALETTE, useSettingsStore } from '@/stores/settings-store';
|
||||
import { resetSharedTags, sharedTagsKey, useSharedTagsStore, type SharedTagsEntry } from '@/stores/shared-tags-store';
|
||||
|
||||
// A message in a group account shows the group's shared tag names and
|
||||
// colours; everywhere else, and wherever the shared ones aren't known, the
|
||||
// user's own tags apply as before.
|
||||
|
||||
const TEAM: TagScope = { loginId: 'login-1', accountId: 'team' };
|
||||
const OTHER_TEAM: TagScope = { loginId: 'login-1', accountId: 'other-team' };
|
||||
|
||||
function setEntry(scope: TagScope, entry: Partial<SharedTagsEntry>) {
|
||||
useSharedTagsStore.setState((state) => ({
|
||||
entries: {
|
||||
...state.entries,
|
||||
[sharedTagsKey(scope.loginId, scope.accountId)]: { status: 'ready', tags: [], stored: null, pending: [], discovered: {}, loadedAt: 1, discoveredAt: 1, readFailedAt: null, ...entry },
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function badge(tagId: string, scope?: TagScope | null) {
|
||||
return render(<TagBadge tagId={tagId} variant="badge" scope={scope} />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetSharedTags();
|
||||
// No signed-in client: the entries below stay what the test sets.
|
||||
useAuthStore.setState({ client: null, activeAccountId: null, getClientForAccount: (() => undefined) as never });
|
||||
useSettingsStore.setState({ emailKeywords: [{ id: 'urgent', label: 'My urgent', color: 'blue' }], nestedTags: false });
|
||||
});
|
||||
afterEach(() => resetSharedTags());
|
||||
|
||||
describe('tag names and colours per account', () => {
|
||||
it('uses the shared definition in a group account and the own one elsewhere', () => {
|
||||
setEntry(TEAM, { tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }] });
|
||||
badge('urgent', TEAM);
|
||||
expect(screen.getByTitle('Urgent')).toHaveClass(KEYWORD_PALETTE.red.fill);
|
||||
badge('urgent', null);
|
||||
expect(screen.getByText('My urgent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the own tags in a group account whose server keeps no metadata', () => {
|
||||
setEntry(TEAM, { status: 'unsupported' });
|
||||
badge('urgent', TEAM);
|
||||
expect(screen.getByText('My urgent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the own tags while the shared ones couldn\'t be read', () => {
|
||||
setEntry(TEAM, { status: 'unavailable', tags: [{ id: 'urgent', label: 'Stale', color: 'red' }] });
|
||||
badge('urgent', TEAM);
|
||||
expect(screen.getByText('My urgent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the own tags in a mailbox a person shared', () => {
|
||||
setEntry(TEAM, { status: 'personal' });
|
||||
badge('urgent', TEAM);
|
||||
expect(screen.getByText('My urgent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies definitions that arrive after the message was drawn', () => {
|
||||
setEntry(TEAM, { status: 'loading' });
|
||||
badge('urgent', TEAM);
|
||||
expect(screen.getByText('My urgent')).toBeInTheDocument();
|
||||
act(() => setEntry(TEAM, { status: 'ready', tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }] }));
|
||||
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('My urgent')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names each message by its own account in a mixed list', () => {
|
||||
setEntry(TEAM, { tags: [{ id: 'urgent', label: 'Team urgent', color: 'red' }] });
|
||||
setEntry(OTHER_TEAM, { tags: [{ id: 'urgent', label: 'Sales urgent', color: 'green' }] });
|
||||
render(
|
||||
<>
|
||||
<TagScopeContext.Provider value={TEAM}><TagBadge tagId="urgent" variant="badge" /></TagScopeContext.Provider>
|
||||
<TagScopeContext.Provider value={OTHER_TEAM}><TagBadge tagId="urgent" variant="badge" /></TagScopeContext.Provider>
|
||||
<TagBadge tagId="urgent" variant="badge" />
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByText('Team urgent')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sales urgent')).toBeInTheDocument();
|
||||
expect(screen.getByText('My urgent')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tag picker in a group account', () => {
|
||||
it('offers the shared tags and every tag found on the messages, not the own ones', () => {
|
||||
setEntry(TEAM, {
|
||||
tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }],
|
||||
discovered: { '$label:waiting-for-customer': 2 },
|
||||
});
|
||||
useSettingsStore.setState({ emailKeywords: [{ id: 'private', label: 'Private', color: 'blue' }] });
|
||||
render(<TagPicker selectedIds={['only-on-this-one']} onToggle={() => {}} scope={TEAM} />);
|
||||
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
||||
expect(screen.getByText('Waiting For Customer')).toBeInTheDocument();
|
||||
// A keyword on the message is never hidden, named or not.
|
||||
expect(screen.getByText('only-on-this-one')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Private')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('tag-picker-shared')).toHaveTextContent('picker_heading');
|
||||
});
|
||||
|
||||
it('offers the own tags without a scope', () => {
|
||||
setEntry(TEAM, { tags: [{ id: 'urgent', label: 'Urgent', color: 'red' }] });
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} scope={null} />);
|
||||
expect(screen.getByText('My urgent')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tag-picker-shared')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tagScopeOf', () => {
|
||||
beforeEach(() => {
|
||||
const client = { getAccountId: () => 'me' };
|
||||
useAuthStore.setState({
|
||||
client: client as never,
|
||||
activeAccountId: 'login-1',
|
||||
getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never,
|
||||
});
|
||||
});
|
||||
|
||||
it('names the group account of a message reached through a login', () => {
|
||||
expect(tagScopeOf({ sourceClientAccountId: 'login-1', sourceAccountId: 'team' })).toEqual(TEAM);
|
||||
});
|
||||
|
||||
it('is null for a message in the login\'s own account', () => {
|
||||
expect(tagScopeOf({ sourceClientAccountId: 'login-1', sourceAccountId: 'me' })).toBeNull();
|
||||
expect(tagScopeOf(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo } from "react";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { groupAccountTags } from "@/lib/shared-tags";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { resolveEmailBlobContext, useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore, type KeywordDefinition } from "@/stores/settings-store";
|
||||
import {
|
||||
loadSharedTags,
|
||||
sharedTagsKey,
|
||||
useSharedTagsStore,
|
||||
type SharedTagsClient,
|
||||
type SharedTagsEntry,
|
||||
type SharedTagsStatus,
|
||||
} from "@/stores/shared-tags-store";
|
||||
|
||||
/**
|
||||
* Which account's tags apply: a JMAP account other than the login's own,
|
||||
* reached through the login `loginId`. Null means the user's own tags.
|
||||
*/
|
||||
export interface TagScope {
|
||||
loginId: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tag scope of whatever is drawn inside: a message row, the open
|
||||
* message, a group account's sidebar section. Tag badges, pickers and
|
||||
* names read it, so each message shows its own account's tags (the unified
|
||||
* inbox mixes accounts).
|
||||
*/
|
||||
export const TagScopeContext = createContext<TagScope | null>(null);
|
||||
|
||||
/** The scope of a message: its own account when that isn't the login's. */
|
||||
export function tagScopeOf(email: Pick<Email, "sourceClientAccountId" | "sourceAccountId"> | null | undefined): TagScope | null {
|
||||
if (!email) return null;
|
||||
try {
|
||||
const auth = useAuthStore.getState();
|
||||
const { client, accountId, clientAccountId } = resolveEmailBlobContext(email, auth.client);
|
||||
if (!client || !accountId || accountId === client.getAccountId()) return null;
|
||||
const loginId = clientAccountId ?? auth.activeAccountId;
|
||||
return loginId ? { loginId, accountId } : null;
|
||||
} catch {
|
||||
// No session to tell (a stand-in store, a signed-out moment): own tags.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* tagScopeOf for a message on screen, kept stable between renders and
|
||||
* worked out again when the view changes (an unstamped message belongs to
|
||||
* the account of the folder on screen).
|
||||
*/
|
||||
export function useEmailTagScope(email: Pick<Email, "sourceClientAccountId" | "sourceAccountId"> | null | undefined): TagScope | null {
|
||||
useEmailStore((state) => state.selectedMailbox);
|
||||
useEmailStore((state) => state.viewingAccountId);
|
||||
useAuthStore((state) => state.activeAccountId);
|
||||
const scope = tagScopeOf(email);
|
||||
const loginId = scope?.loginId;
|
||||
const accountId = scope?.accountId;
|
||||
return useMemo(() => (loginId && accountId ? { loginId, accountId } : null), [loginId, accountId]);
|
||||
}
|
||||
|
||||
/** The scope shared by every message given, or null when they differ. */
|
||||
export function commonTagScope(emails: Array<Pick<Email, "sourceClientAccountId" | "sourceAccountId">>): TagScope | null {
|
||||
const scopes = emails.map((email) => tagScopeOf(email));
|
||||
const first = scopes[0] ?? null;
|
||||
const same = scopes.every((scope) => scope?.loginId === first?.loginId && scope?.accountId === first?.accountId);
|
||||
return same ? first : null;
|
||||
}
|
||||
|
||||
export function clientForLogin(loginId: string): SharedTagsClient | null {
|
||||
const auth = useAuthStore.getState();
|
||||
return (auth.getClientForAccount?.(loginId) as SharedTagsClient | undefined)
|
||||
?? (loginId === auth.activeAccountId ? (auth.client as SharedTagsClient | null) : null);
|
||||
}
|
||||
|
||||
/** Starts loading a scope's shared tags (once per sign-in). */
|
||||
export function ensureSharedTags(scope: TagScope): void {
|
||||
const client = clientForLogin(scope.loginId);
|
||||
if (client) void loadSharedTags(sharedTagsKey(scope.loginId, scope.accountId), scope.accountId, client);
|
||||
}
|
||||
|
||||
const derived = new WeakMap<SharedTagsEntry, Map<boolean, KeywordDefinition[]>>();
|
||||
|
||||
/** The shared definitions plus the tags found on the messages, worked out once per entry. */
|
||||
function groupDefinitions(entry: SharedTagsEntry, nested: boolean): KeywordDefinition[] {
|
||||
let byNesting = derived.get(entry);
|
||||
if (!byNesting) {
|
||||
byNesting = new Map();
|
||||
derived.set(entry, byNesting);
|
||||
}
|
||||
let definitions = byNesting.get(nested);
|
||||
if (!definitions) {
|
||||
definitions = groupAccountTags(entry.tags, entry.discovered, nested).definitions;
|
||||
byNesting.set(nested, definitions);
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
||||
export interface AccountTags {
|
||||
/** The tags to offer and name things by in this scope. */
|
||||
definitions: KeywordDefinition[];
|
||||
/** True when they are the group account's shared tags. */
|
||||
shared: boolean;
|
||||
/** `personal` for the user's own account. */
|
||||
status: SharedTagsStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags of a scope: in a group account whose server keeps metadata, its
|
||||
* shared definitions plus every tag found on its messages; everywhere else
|
||||
* (own account, a mailbox a person shared, a server without metadata, while
|
||||
* loading) the user's own tags. Definitions that arrive after the first
|
||||
* render apply as soon as they are there.
|
||||
*
|
||||
* `scope` undefined reads TagScopeContext; null means the user's own tags.
|
||||
*/
|
||||
export function useAccountTags(scope?: TagScope | null): AccountTags {
|
||||
const contextScope = useContext(TagScopeContext);
|
||||
const effective = scope === undefined ? contextScope : scope;
|
||||
const key = effective ? sharedTagsKey(effective.loginId, effective.accountId) : null;
|
||||
const entry = useSharedTagsStore((state) => (key ? state.entries[key] : undefined));
|
||||
const personal = useSettingsStore((state) => state.emailKeywords);
|
||||
const nested = useSettingsStore((state) => state.nestedTags);
|
||||
const loginId = effective?.loginId;
|
||||
const accountId = effective?.accountId;
|
||||
|
||||
useEffect(() => {
|
||||
if (loginId && accountId) ensureSharedTags({ loginId, accountId });
|
||||
}, [loginId, accountId]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!effective) return { definitions: personal, shared: false, status: "personal" };
|
||||
if (entry?.status !== "ready") return { definitions: personal, shared: false, status: entry?.status ?? "loading" };
|
||||
return { definitions: groupDefinitions(entry, nested), shared: true, status: "ready" };
|
||||
// `effective` is fully described by the key.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, entry, personal, nested]);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/stores/settings-store";
|
||||
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
|
||||
import { suggestKeywordColor } from "@/lib/keyword-discovery";
|
||||
import { useAccountTags, type TagScope } from "@/hooks/use-account-tags";
|
||||
|
||||
/**
|
||||
* Names and colours tags for the screen, bound to the user's tag settings.
|
||||
@@ -18,9 +19,13 @@ import { suggestKeywordColor } from "@/lib/keyword-discovery";
|
||||
* someone who never asked for nesting. Subscribing to them also keeps tags in
|
||||
* step the moment either changes: reading the store inside the formatter would
|
||||
* leave every list stale until something else happened to re-render it.
|
||||
*
|
||||
* The definitions are those of the account in `scope` (useAccountTags): a
|
||||
* group account's shared tags, otherwise the user's own. Without `scope`
|
||||
* the TagScopeContext around the caller decides.
|
||||
*/
|
||||
export function useKeywordFormat() {
|
||||
const keywords = useSettingsStore((state) => state.emailKeywords);
|
||||
export function useKeywordFormat(scope?: TagScope | null) {
|
||||
const keywords = useAccountTags(scope).definitions;
|
||||
const nested = useSettingsStore((state) => state.nestedTags);
|
||||
|
||||
return useMemo(
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { JMAP_URL } from './helpers/config';
|
||||
import { expandSharedFolders, emailItem, folderRow } from './helpers/app';
|
||||
import {
|
||||
ORG,
|
||||
ORG_PASSWORD,
|
||||
ORG_SERVER,
|
||||
adminContext,
|
||||
getPolicy,
|
||||
putPolicy,
|
||||
requireOrgEnv,
|
||||
shot,
|
||||
userPage,
|
||||
} from './helpers/org';
|
||||
|
||||
/**
|
||||
* Shared tags in group mailboxes (organisation feature 6), through the real
|
||||
* UI. On the v1.0.0 release (label 1.0.0) the definitions live in the
|
||||
* metadata of org-team's Inbox: org-alice defines "Urgent" in red and tags a
|
||||
* message, org-bob sees the same name and colour, and a group scope that
|
||||
* locks `sharedTags` makes the editor read-only. On servers without working
|
||||
* metadata (0.16, the 1.0 test build) there is no shared editor, the tags on
|
||||
* group messages still show, and the app stays online.
|
||||
*
|
||||
* Cleanup: the test message (and with it its tags) is destroyed, the
|
||||
* `org.bulwarkmail.tags` metadata entry is set back to null, the policy is
|
||||
* put back. Other `org.bulwarkmail.*` entries (templates) are never touched.
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const SUBJECT = `Shared tags ${Date.now()}`;
|
||||
const OWN_SUBJECT = `Own tags ${Date.now()}`;
|
||||
const HAS_METADATA = ORG_SERVER === '1.0.0';
|
||||
|
||||
// --- JMAP, as org-alice, straight to the server --------------------------
|
||||
|
||||
type Call = [string, Record<string, unknown>, string];
|
||||
let api: { url: string; team: string; inbox: string; own: string; ownInbox: string } | null = null;
|
||||
let messageId: string | null = null;
|
||||
/** A message in org-alice's own Inbox with the same keyword, for the unified inbox. */
|
||||
let ownMessageId: string | null = null;
|
||||
|
||||
const auth = () => `Basic ${Buffer.from(`${ORG.alice}:${ORG_PASSWORD}`).toString('base64')}`;
|
||||
|
||||
/** The API url, org-team's account id and its Inbox id, looked up once. */
|
||||
async function ensureApi(): Promise<NonNullable<typeof api>> {
|
||||
if (!api) {
|
||||
const session = await (await fetch(`${JMAP_URL}/.well-known/jmap`, { headers: { Authorization: auth() } })).json();
|
||||
const team = Object.entries(session.accounts as Record<string, { name: string }>).find(([, a]) => a.name === ORG.team)![0];
|
||||
const own = session.primaryAccounts['urn:ietf:params:jmap:mail'] as string;
|
||||
api = { url: `${JMAP_URL}${new URL(session.apiUrl).pathname}`, team, inbox: '', own, ownInbox: '' };
|
||||
const res = await jmapRaw([
|
||||
['Mailbox/query', { accountId: team, filter: { role: 'inbox' } }, 'q'],
|
||||
['Mailbox/query', { accountId: own, filter: { role: 'inbox' } }, 'o'],
|
||||
]);
|
||||
api.inbox = res[0][1].ids[0];
|
||||
api.ownInbox = res[1][1].ids[0];
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
async function jmap(calls: Call[], using?: string[]) {
|
||||
await ensureApi();
|
||||
return jmapRaw(calls, using);
|
||||
}
|
||||
|
||||
async function jmapRaw(calls: Call[], using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']) {
|
||||
const res = await fetch(api!.url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: auth(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ using, methodCalls: calls }),
|
||||
});
|
||||
expect(res.ok, `JMAP ${res.status}`).toBe(true);
|
||||
return (await res.json()).methodResponses as Array<[string, any, string]>;
|
||||
}
|
||||
|
||||
const META_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:metadata'];
|
||||
|
||||
async function sharedTagsOnServer(): Promise<unknown> {
|
||||
const res = await jmap([['Mailbox/get', { accountId: api!.team, ids: [api!.inbox], properties: ['metadata'] }, 'g']], META_USING);
|
||||
return res[0][1].list[0].metadata?.['org.bulwarkmail.tags'] ?? null;
|
||||
}
|
||||
|
||||
async function clearSharedTags(): Promise<void> {
|
||||
await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags': null } } }, 's']], META_USING);
|
||||
}
|
||||
|
||||
async function messageKeywords(): Promise<Record<string, boolean>> {
|
||||
const res = await jmap([['Email/get', { accountId: api!.team, ids: [messageId], properties: ['keywords'] }, 'g']]);
|
||||
return res[0][1].list[0].keywords;
|
||||
}
|
||||
|
||||
// --- UI helpers ----------------------------------------------------------
|
||||
|
||||
async function openTeamInbox(page: Page): Promise<void> {
|
||||
await expandSharedFolders(page, ORG.team);
|
||||
await folderRow(page, { role: 'inbox', shared: true }).first().click();
|
||||
await expect(emailItem(page, SUBJECT).first()).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
/** Settings > Folders & tags, scrolled to the shared tags of org-team. */
|
||||
async function openSharedTagSettings(page: Page, phone = false) {
|
||||
if (phone) {
|
||||
await page.goto('/settings');
|
||||
await page.getByRole('button', { name: 'Folders & tags' }).or(page.getByRole('link', { name: 'Folders & tags' })).first().click();
|
||||
} else {
|
||||
await page.goto('/settings/organize/shared-tags');
|
||||
}
|
||||
// The block is drawn again once the tags have loaded.
|
||||
const account = page.locator('[data-testid="shared-tags-account"]:not([data-status="loading"])').filter({ hasText: ORG.team });
|
||||
await expect(account).toBeVisible({ timeout: 30000 });
|
||||
await account.scrollIntoViewIfNeeded();
|
||||
return account;
|
||||
}
|
||||
|
||||
let original: Record<string, unknown>;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
requireOrgEnv();
|
||||
const admin = await adminContext(browser);
|
||||
original = await getPolicy(admin);
|
||||
await putPolicy(admin, { ...original, restrictions: {}, scopes: [] });
|
||||
await admin.close();
|
||||
|
||||
// A message in org-team's Inbox; on servers without metadata it carries a
|
||||
// tag set by another client, which must still show.
|
||||
const keywords = HAS_METADATA ? {} : { '$label:from-another-client': true };
|
||||
const { team, inbox, own, ownInbox } = await ensureApi();
|
||||
const message = (mailbox: string, to: string, subject: string, kw: Record<string, boolean>) => ({
|
||||
mailboxIds: { [mailbox]: true },
|
||||
from: [{ name: 'Org Alice', email: ORG.alice }],
|
||||
to: [{ email: to }],
|
||||
subject,
|
||||
keywords: kw,
|
||||
bodyValues: { b: { value: 'A message for the shared tags spec.' } },
|
||||
textBody: [{ partId: 'b', type: 'text/plain' }],
|
||||
});
|
||||
const res = await jmap([['Email/set', { accountId: team, create: { m: message(inbox, ORG.team, SUBJECT, keywords) } }, 'c']]);
|
||||
messageId = res[0][1].created.m.id;
|
||||
if (HAS_METADATA) {
|
||||
const mine = await jmap([['Email/set', { accountId: own, create: { m: message(ownInbox, ORG.alice, OWN_SUBJECT, { '$label:urgent': true }) } }, 'c']]);
|
||||
ownMessageId = mine[0][1].created.m.id;
|
||||
}
|
||||
if (HAS_METADATA) await clearSharedTags();
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (messageId) await jmap([['Email/set', { accountId: api!.team, destroy: [messageId] }, 'd']]);
|
||||
if (ownMessageId) await jmap([['Email/set', { accountId: api!.own, destroy: [ownMessageId] }, 'd']]);
|
||||
if (HAS_METADATA && api) {
|
||||
await clearSharedTags();
|
||||
expect(await sharedTagsOnServer()).toBeNull();
|
||||
}
|
||||
const admin = await adminContext(browser);
|
||||
await putPolicy(admin, original);
|
||||
await admin.close();
|
||||
});
|
||||
|
||||
test.describe('on a server with metadata', () => {
|
||||
test.skip(!HAS_METADATA, 'shared definitions need Stalwart v1.0.0 metadata');
|
||||
|
||||
test('org-alice defines "Urgent" in red for org-team and tags a message with it', async ({ browser }) => {
|
||||
const alice = await userPage(browser, ORG.alice);
|
||||
const account = await openSharedTagSettings(alice);
|
||||
await account.getByRole('button', { name: 'Add shared tag' }).click();
|
||||
const form = account.getByTestId('shared-tag-form');
|
||||
await form.getByLabel('Name').fill('Urgent');
|
||||
await form.getByRole('button', { name: 'red', exact: true }).click();
|
||||
await form.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(account.locator('[data-testid="shared-tag-row"][data-tag-id="urgent"]')).toBeVisible();
|
||||
await shot(alice, 'tags-settings-editor');
|
||||
await expect.poll(sharedTagsOnServer).toEqual({ tags: { urgent: { label: 'Urgent', color: 'red' } } });
|
||||
|
||||
await alice.goto('/');
|
||||
await openTeamInbox(alice);
|
||||
// The group's tags are listed under its folders.
|
||||
await expect(alice.getByTestId('shared-account-tags').getByText('Urgent')).toBeVisible();
|
||||
|
||||
const row = emailItem(alice, SUBJECT).first();
|
||||
await expect(async () => {
|
||||
await row.click({ button: 'right' });
|
||||
await alice.getByTestId('ctx-tag').waitFor({ state: 'visible', timeout: 2000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
await alice.getByTestId('ctx-tag').hover();
|
||||
await expect(alice.getByTestId('tag-picker-shared')).toBeVisible();
|
||||
await shot(alice, 'tags-picker');
|
||||
await alice.getByRole('menuitemcheckbox', { name: 'Urgent' }).click();
|
||||
await expect.poll(async () => (await messageKeywords())['$label:urgent']).toBe(true);
|
||||
await alice.keyboard.press('Escape');
|
||||
await expect(row.getByTitle('Urgent')).toBeVisible();
|
||||
|
||||
// The group's tag in the sidebar counts and lists that group's mail
|
||||
// only: her own message with the same keyword stays out.
|
||||
const sharedRow = alice.getByTestId('shared-account-tags').getByTestId('folder-row').filter({ hasText: 'Urgent' });
|
||||
await expect(sharedRow.getByTestId('folder-counts')).toHaveAttribute('data-total', '1', { timeout: 30000 });
|
||||
await sharedRow.click();
|
||||
await expect(emailItem(alice, SUBJECT).first()).toBeVisible({ timeout: 30000 });
|
||||
await expect(emailItem(alice, OWN_SUBJECT)).toHaveCount(0);
|
||||
await expect(sharedRow).toHaveAttribute('data-selected', 'true');
|
||||
await shot(alice, 'tags-sidebar-group-view');
|
||||
await alice.context().close();
|
||||
});
|
||||
|
||||
test('org-bob sees the same name and colour on that message', async ({ browser }) => {
|
||||
const bob = await userPage(browser, ORG.bob);
|
||||
await openTeamInbox(bob);
|
||||
const chip = emailItem(bob, SUBJECT).first().getByTitle('Urgent');
|
||||
await expect(chip).toBeVisible();
|
||||
await expect(chip).toHaveClass(/bg-red-500\/10/);
|
||||
await expect(bob.getByTestId('shared-account-tags').getByText('Urgent')).toBeVisible();
|
||||
await shot(bob, 'tags-member-view');
|
||||
|
||||
// Another member's edit arrives without a reload (push: the Inbox's
|
||||
// Mailbox state changes with its metadata).
|
||||
await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags/tags/urgent': { label: 'Very urgent', color: 'orange' } } } }, 's']], META_USING);
|
||||
const renamed = emailItem(bob, SUBJECT).first().getByTitle('Very urgent');
|
||||
await expect(renamed).toBeVisible({ timeout: 30000 });
|
||||
await expect(renamed).toHaveClass(/bg-orange-500\/10/);
|
||||
await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags/tags/urgent': { label: 'Urgent', color: 'red' } } } }, 's']], META_USING);
|
||||
await expect(emailItem(bob, SUBJECT).first().getByTitle('Urgent')).toBeVisible({ timeout: 30000 });
|
||||
await bob.context().close();
|
||||
});
|
||||
|
||||
test('the unified inbox names the tags of each message by its own account', async ({ browser }) => {
|
||||
// Unified inbox with the group inbox in it, pinned by the policy so a
|
||||
// synced setting of the account can't turn it off.
|
||||
const admin = await adminContext(browser);
|
||||
const policy = await getPolicy(admin);
|
||||
await putPolicy(admin, {
|
||||
...policy,
|
||||
restrictions: {
|
||||
...(policy.restrictions as Record<string, unknown>),
|
||||
enableUnifiedMailbox: { locked: true, value: true },
|
||||
includeGroupInUnified: { locked: true, value: true },
|
||||
},
|
||||
});
|
||||
const alice = await userPage(browser, ORG.alice);
|
||||
await folderRow(alice, { name: 'unified-inbox' }).first().click({ timeout: 60000 });
|
||||
// The same keyword: the group's shared name on the team message, the
|
||||
// bare keyword on her own (she has no tag of that name).
|
||||
await expect(emailItem(alice, SUBJECT).first().getByTitle('Urgent')).toBeVisible({ timeout: 30000 });
|
||||
await expect(emailItem(alice, OWN_SUBJECT).first().getByTitle('urgent', { exact: true })).toBeVisible();
|
||||
await shot(alice, 'tags-unified-inbox');
|
||||
await alice.context().close();
|
||||
await putPolicy(admin, policy);
|
||||
await admin.close();
|
||||
});
|
||||
|
||||
test('deleting a shared tag warns that it is for everyone, at phone width too', async ({ browser }) => {
|
||||
const alice = await userPage(browser, ORG.alice, true);
|
||||
const account = await openSharedTagSettings(alice, true);
|
||||
await account.getByRole('button', { name: 'Delete Urgent' }).click();
|
||||
const confirm = account.getByTestId('shared-tag-delete-confirm');
|
||||
await expect(confirm.getByText('Delete for everyone? The tag stays on its messages.')).toBeVisible();
|
||||
for (const name of ['Delete', 'Cancel']) {
|
||||
const box = await confirm.getByRole('button', { name, exact: true }).boundingBox();
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
await shot(alice, 'tags-delete-confirm-phone');
|
||||
await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(account.getByRole('button', { name: 'Edit Urgent' })).toBeFocused();
|
||||
expect(await sharedTagsOnServer()).toEqual({ tags: { urgent: { label: 'Urgent', color: 'red' } } });
|
||||
await alice.context().close();
|
||||
});
|
||||
|
||||
test('a lock applies only to the scope that sets it, read-only with the reason in place', async ({ browser }) => {
|
||||
const admin = await adminContext(browser);
|
||||
let policy = await getPolicy(admin);
|
||||
await putPolicy(admin, {
|
||||
...policy,
|
||||
scopes: [
|
||||
{ id: 'org-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: { restrictions: { sharedTags: { locked: true } } } },
|
||||
{ id: 'org-team', label: 'Org team', match: { groups: [ORG.team] }, policy: {} },
|
||||
],
|
||||
});
|
||||
|
||||
// A domain scope for another domain leaves org-team's members alone.
|
||||
const alice = await userPage(browser, ORG.alice);
|
||||
const aliceTags = await openSharedTagSettings(alice);
|
||||
await expect(aliceTags.getByRole('button', { name: 'Add shared tag' })).toBeVisible();
|
||||
await alice.context().close();
|
||||
|
||||
// The admin locks shared tags for the org-team group on Organisation > Sharing.
|
||||
const page = await admin.newPage();
|
||||
await page.goto('/admin/org-sharing');
|
||||
await expect(page.getByRole('heading', { name: 'Sharing' }).first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Org team' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
const section = page.getByTestId('org-shared-tags-section');
|
||||
await section.getByLabel('Lock').check();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(page.getByText('Saved.')).toBeVisible();
|
||||
await section.scrollIntoViewIfNeeded();
|
||||
await shot(page, 'tags-admin-lock');
|
||||
policy = await getPolicy(admin);
|
||||
const team = (policy.scopes as Array<{ id: string; policy: Record<string, any> }>).find((s) => s.id === 'org-team')!;
|
||||
expect(team.policy.restrictions.sharedTags).toEqual({ locked: true });
|
||||
expect((policy.restrictions as Record<string, unknown>).sharedTags).toBeUndefined();
|
||||
await admin.close();
|
||||
|
||||
const bob = await userPage(browser, ORG.bob);
|
||||
const bobTags = await openSharedTagSettings(bob);
|
||||
await expect(bobTags.getByText('Set by your organisation')).toBeVisible();
|
||||
await expect(bobTags.getByRole('button', { name: 'Add shared tag' })).toHaveCount(0);
|
||||
await expect(bobTags.getByRole('button', { name: /^(Edit|Delete|Name) / })).toHaveCount(0);
|
||||
await expect(bobTags.locator('[data-tag-id="urgent"]')).toBeVisible();
|
||||
await shot(bob, 'tags-settings-locked');
|
||||
// Tagging with the shared tags still works.
|
||||
await bob.goto('/');
|
||||
await openTeamInbox(bob);
|
||||
await expect(emailItem(bob, SUBJECT).first().getByTitle('Urgent')).toBeVisible();
|
||||
await bob.context().close();
|
||||
});
|
||||
|
||||
test('the shared tags work at phone width', async ({ browser }) => {
|
||||
const bob = await userPage(browser, ORG.bob, true);
|
||||
const account = await openSharedTagSettings(bob, true);
|
||||
await expect(account.getByText('Set by your organisation')).toBeVisible();
|
||||
await expect(account.locator('[data-tag-id="urgent"]')).toBeVisible();
|
||||
const overflow = await bob.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
await shot(bob, 'tags-settings-phone');
|
||||
await bob.context().close();
|
||||
|
||||
const admin = await adminContext(browser, true);
|
||||
const page = await admin.newPage();
|
||||
await page.goto('/admin/org-sharing');
|
||||
await expect(page.getByTestId('org-shared-tags-section')).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
|
||||
await page.getByTestId('org-shared-tags-section').scrollIntoViewIfNeeded();
|
||||
await shot(page, 'tags-admin-phone');
|
||||
await admin.close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('on a server without metadata', () => {
|
||||
test.skip(HAS_METADATA, 'covered above');
|
||||
|
||||
test('no shared editor, the tags on group messages still show, and the app stays online', async ({ browser }) => {
|
||||
const alice = await userPage(browser, ORG.alice);
|
||||
const requests: string[] = [];
|
||||
alice.on('request', (request) => {
|
||||
const body = request.postData() ?? '';
|
||||
if (body.includes('urn:ietf:params:jmap:metadata')) requests.push(body);
|
||||
});
|
||||
await openTeamInbox(alice);
|
||||
await expect(emailItem(alice, SUBJECT).first().getByTitle('from-another-client')).toBeVisible();
|
||||
await expect(alice.getByTestId('shared-account-tags')).toHaveCount(0);
|
||||
|
||||
const account = await openSharedTagSettings(alice);
|
||||
// 0.16 has no metadata; the 1.0 test build has it but drops the request.
|
||||
await expect(account.getByTestId(ORG_SERVER === '1.0' ? 'shared-tags-unavailable' : 'shared-tags-unsupported')).toBeVisible();
|
||||
await expect(account.getByRole('button', { name: 'Add shared tag' })).toHaveCount(0);
|
||||
await shot(alice, 'tags-settings-unsupported');
|
||||
|
||||
// The 1.0 test build drops the connection on Mailbox/get with metadata;
|
||||
// that must not leave the app reconnecting. Had the failed read reported
|
||||
// the login offline (onConnectionChange), the banner would stay until the
|
||||
// next good keep-alive ping, 30 s later. 0.16 gets no such request.
|
||||
await alice.goto('/');
|
||||
await openTeamInbox(alice);
|
||||
await alice.waitForTimeout(3000);
|
||||
await expect(alice.getByText('Connection lost. Attempting to reconnect')).toHaveCount(0);
|
||||
if (ORG_SERVER === '0.16') expect(requests).toEqual([]);
|
||||
// The 1.0 test build advertises metadata, so it was asked (and dropped it).
|
||||
if (ORG_SERVER === '1.0') expect(requests.length).toBeGreaterThan(0);
|
||||
await alice.context().close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
EMPTY_SHARED_TAGS,
|
||||
groupAccountTags,
|
||||
parseSharedTags,
|
||||
planSharedTagsSave,
|
||||
readSharedTags,
|
||||
sharedDefinitions,
|
||||
} from '../shared-tags';
|
||||
import { MetadataTooLargeError } from '../jmap/bulwark-metadata';
|
||||
|
||||
// Shared tag definitions of a group account, as they sit in the metadata of
|
||||
// its Inbox: a map by tag id under `tags`, changed one tag at a time.
|
||||
|
||||
const MAP = {
|
||||
tags: {
|
||||
urgent: { label: 'Urgent', color: 'red' },
|
||||
other: { label: 'Other', color: 'chartreuse', icon: 'flame' },
|
||||
},
|
||||
note: 'kept',
|
||||
};
|
||||
|
||||
describe('readSharedTags', () => {
|
||||
it('reads the map by id and keeps fields it doesn\'t know', () => {
|
||||
const stored = readSharedTags(MAP);
|
||||
expect(stored.patchable).toBe(true);
|
||||
expect(stored.entries.other).toEqual({ label: 'Other', color: 'chartreuse', icon: 'flame' });
|
||||
expect(stored.extra).toEqual({ note: 'kept' });
|
||||
});
|
||||
|
||||
it('reads the earlier list and bare-array shapes, for converting', () => {
|
||||
const fromList = readSharedTags({ list: [{ id: 'urgent', label: 'Urgent', color: 'red' }, { id: 'URGENT', label: 'Twice' }] });
|
||||
expect(fromList).toEqual({ entries: { urgent: { label: 'Urgent', color: 'red' } }, extra: {}, patchable: false });
|
||||
expect(readSharedTags([{ id: 'later', label: 'Later', color: 'blue' }]).entries).toEqual({ later: { label: 'Later', color: 'blue' } });
|
||||
});
|
||||
|
||||
it('drops what cannot be a tag', () => {
|
||||
const stored = readSharedTags({ tags: { 'has space': { label: 'x' }, 'ctl\u0001': { label: 'x' }, fine: 'not an object', ok: {} } });
|
||||
expect(Object.keys(stored.entries)).toEqual(['ok']);
|
||||
expect(readSharedTags(undefined)).toEqual(EMPTY_SHARED_TAGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedDefinitions', () => {
|
||||
it('names each tag, shows a color it doesn\'t know as grey, and sorts by name', () => {
|
||||
expect(sharedDefinitions(readSharedTags(MAP))).toEqual([
|
||||
{ id: 'other', label: 'Other', color: 'gray' },
|
||||
{ id: 'urgent', label: 'Urgent', color: 'red' },
|
||||
]);
|
||||
expect(parseSharedTags({ tags: { x: { color: 'blue' } } })).toEqual([{ id: 'x', label: 'x', color: 'blue' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planSharedTagsSave', () => {
|
||||
it('patches only the tags that change', () => {
|
||||
const { patches, result } = planSharedTagsSave(readSharedTags(MAP), [
|
||||
{ id: 'new/child', value: { label: 'Child', color: 'blue' } },
|
||||
{ id: 'URGENT', value: null },
|
||||
]);
|
||||
expect(patches).toEqual([
|
||||
{ path: ['tags', 'new/child'], value: { label: 'Child', color: 'blue' } },
|
||||
{ path: ['tags', 'urgent'], value: null },
|
||||
]);
|
||||
expect(Object.keys(result.entries).sort()).toEqual(['new/child', 'other']);
|
||||
});
|
||||
|
||||
it('keeps the fields of a changed tag it doesn\'t know, and its color unless one was picked', () => {
|
||||
const { patches } = planSharedTagsSave(readSharedTags(MAP), [{ id: 'other', value: { label: 'Renamed' } }]);
|
||||
expect(patches).toEqual([{ path: ['tags', 'other'], value: { label: 'Renamed', color: 'chartreuse', icon: 'flame' } }]);
|
||||
});
|
||||
|
||||
it('writes an earlier shape once, whole, as the map', () => {
|
||||
const { patches, result } = planSharedTagsSave(
|
||||
readSharedTags({ list: [{ id: 'urgent', label: 'Urgent', color: 'red' }] }),
|
||||
[{ id: 'later', value: { label: 'Later', color: 'blue' } }],
|
||||
);
|
||||
expect(patches).toEqual([{ path: [], value: { tags: { urgent: { label: 'Urgent', color: 'red' }, later: { label: 'Later', color: 'blue' } } } }]);
|
||||
expect(result.patchable).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses more than 8 KB of UTF-8, counted in bytes not characters', () => {
|
||||
// 100 names of 30 two-byte characters: about 4.9 K characters, over 8 K bytes.
|
||||
const changes = Array.from({ length: 100 }, (_, i) => ({ id: `t${i}`, value: { label: 'ä'.repeat(30), color: 'red' } }));
|
||||
const json = JSON.stringify({ tags: Object.fromEntries(changes.map((c) => [c.id, c.value])) });
|
||||
expect(json.length).toBeLessThan(8192);
|
||||
expect(() => planSharedTagsSave(EMPTY_SHARED_TAGS, changes)).toThrow(MetadataTooLargeError);
|
||||
expect(() => planSharedTagsSave(EMPTY_SHARED_TAGS, changes.slice(0, 50))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupAccountTags', () => {
|
||||
it('lists the shared definitions, then every tag on the messages nobody named', () => {
|
||||
const { definitions, discoveredIds } = groupAccountTags(
|
||||
[{ id: 'urgent', label: 'Urgent', color: 'red' }],
|
||||
{ '$label:urgent': 4, '$label:q3-invoices': 2 },
|
||||
false,
|
||||
);
|
||||
expect(definitions.map((t) => t.id)).toEqual(['urgent', 'q3-invoices']);
|
||||
expect(definitions[1].label).toBe('Q3 Invoices');
|
||||
expect(discoveredIds).toEqual(['q3-invoices']);
|
||||
});
|
||||
|
||||
it('names a tag by its shared definition whatever case the message uses', () => {
|
||||
const { definitions } = groupAccountTags([{ id: 'urgent', label: 'Urgent', color: 'red' }], { '$label:Urgent': 1 }, false);
|
||||
expect(definitions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,15 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [
|
||||
page: 'org-templates',
|
||||
modes: ['locked'],
|
||||
},
|
||||
{
|
||||
key: 'sharedTags',
|
||||
label: 'Shared tags',
|
||||
description: "Lock: users can tag mail with the shared tags of their group mailboxes but can't add, rename or delete them. Unlock it in a group scope for the people who look after the tags.",
|
||||
category: 'Sharing',
|
||||
type: 'boolean',
|
||||
page: 'org-sharing',
|
||||
modes: ['locked'],
|
||||
},
|
||||
{ key: 'delegation', label: 'Delegates', description: 'Hide removes Settings > Sharing > Delegates, so users can no longer give someone access to mail, calendars and contacts in one step.', category: 'Sharing', type: 'boolean', page: 'org-sharing', modes: ['hidden'] },
|
||||
{ key: 'sharingRights', label: 'Rights users can give', description: 'Untick "write" and every share dialog in Bulwark (folders, calendars, address books, files, Delegates) offers read access only.', category: 'Sharing', type: 'enum', allowedValues: ['read', 'write'], page: 'org-sharing', modes: ['allowedValues'] },
|
||||
];
|
||||
|
||||
@@ -46,6 +46,23 @@ export class MetadataUnsupportedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** A patch inside an entry whose parent value doesn't exist (yet, or any more). */
|
||||
export class MetadataPatchError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'MetadataPatchError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One segment of a patch path inside a metadata entry, escaped the JSON
|
||||
* Pointer way (`~` as `~0`, `/` as `~1`), which Stalwart v1.0.0 decodes:
|
||||
* a tag id `work/clients` is the segment `work~1clients`.
|
||||
*/
|
||||
export function escapeMetadataPathSegment(segment: string): string {
|
||||
return segment.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||
}
|
||||
|
||||
/** `tags`, `template.<id>`: lowercase, digits, dashes, dot-separated. */
|
||||
export function isValidMetadataKey(key: string): boolean {
|
||||
return key.length <= 120 && KEY_RE.test(key);
|
||||
|
||||
@@ -297,6 +297,8 @@ export interface IJMAPClient {
|
||||
limit?: number;
|
||||
onProgress?: (scanned: number, total: number) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Another account of this login, e.g. a group account (default: the own one). */
|
||||
accountId?: string;
|
||||
}): Promise<{ keywords: Record<string, number>; scanned: number; total: number; complete: boolean }>;
|
||||
/** Per-tab unread counts for message-list category tabs (filter = resolved tab fragment, null = unfiltered). */
|
||||
getCategoryUnreadCounts(mailboxId: string, tabs: Array<{ id: string; filter: Record<string, unknown> | null }>, accountId?: string): Promise<Record<string, number>>;
|
||||
@@ -622,6 +624,8 @@ export interface IJMAPClient {
|
||||
supportsBulwarkMetadata?(accountId?: string): boolean;
|
||||
getBulwarkMetadata?(accountId?: string, options?: { private?: boolean }): Promise<Record<string, unknown> | null>;
|
||||
setBulwarkMetadata?(accountId: string | undefined, key: string, value: unknown, options?: { private?: boolean }): Promise<void>;
|
||||
advertisesBulwarkMetadata?(accountId?: string): boolean;
|
||||
patchBulwarkMetadata?(accountId: string | undefined, key: string, patches: Array<{ path: string[]; value: unknown }>): Promise<{ newState?: string }>;
|
||||
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
|
||||
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
||||
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
|
||||
|
||||
+44
-4
@@ -1,4 +1,4 @@
|
||||
import { advertisesMailboxMetadata, bulwarkEntries, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
|
||||
import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
|
||||
import { IncompleteDeleteError } from "./delete-error";
|
||||
import { ArchiveEmailsError } from "./archive-error";
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
@@ -2673,7 +2673,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
limit?: number;
|
||||
onProgress?: (scanned: number, total: number) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Another account of this login, e.g. a group account (default: the own one). */
|
||||
accountId?: string;
|
||||
}): Promise<{ keywords: Record<string, number>; scanned: number; total: number; complete: boolean }> {
|
||||
const accountId = options?.accountId || this.accountId;
|
||||
const cap = Math.max(0, options?.limit ?? DEFAULT_KEYWORD_SCAN_LIMIT);
|
||||
const pageSize = Math.max(1, Math.min(500, this.getMaxObjectsInGet()));
|
||||
const keywords: Record<string, number> = {};
|
||||
@@ -2687,14 +2690,14 @@ export class JMAPClient implements IJMAPClient {
|
||||
try {
|
||||
const response = await this.request([
|
||||
["Email/query", {
|
||||
accountId: this.accountId,
|
||||
accountId,
|
||||
sort: [{ property: "receivedAt", isAscending: false }],
|
||||
limit: Math.min(pageSize, cap - scanned),
|
||||
position: scanned,
|
||||
calculateTotal: scanned === 0,
|
||||
}, "0"],
|
||||
["Email/get", {
|
||||
accountId: this.accountId,
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
|
||||
properties: ["keywords"],
|
||||
}, "1"],
|
||||
@@ -7160,6 +7163,40 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (value !== null && metadataValueBytes(value) > MAX_METADATA_ENTRY_BYTES) {
|
||||
throw new MetadataTooLargeError(`The entry is larger than ${MAX_METADATA_ENTRY_BYTES} bytes`);
|
||||
}
|
||||
await this.updateInboxMetadata(id, { [`${property}/${namespaceFor(key)}`]: value }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account advertises vendor metadata on mailboxes, whatever
|
||||
* its last request did (supportsBulwarkMetadata also pauses after a
|
||||
* failure). False means the server keeps no metadata for this account.
|
||||
*/
|
||||
advertisesBulwarkMetadata(accountId?: string): boolean {
|
||||
const id = accountId || this.accountId;
|
||||
return advertisesMailboxMetadata(this.accounts[id]?.accountCapabilities as Record<string, unknown> | undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes values inside one Bulwark metadata entry of the account's Inbox
|
||||
* and nothing else: each patch path (segments, escaped here) is set, null
|
||||
* removes it, an empty path is the whole entry. Returns the Mailbox state
|
||||
* after the change, so a caller can recognise the push of its own write.
|
||||
* Throws MetadataTooLargeError (the server's size limit),
|
||||
* MetadataPatchError (a parent of a path doesn't exist) and
|
||||
* MetadataUnsupportedError.
|
||||
*/
|
||||
async patchBulwarkMetadata(accountId: string | undefined, key: string, patches: Array<{ path: string[]; value: unknown }>): Promise<{ newState?: string }> {
|
||||
const id = accountId || this.accountId;
|
||||
if (!this.supportsBulwarkMetadata(id)) throw new MetadataUnsupportedError();
|
||||
const update: Record<string, unknown> = {};
|
||||
for (const { path, value } of patches) {
|
||||
update[[`metadata/${namespaceFor(key)}`, ...path.map(escapeMetadataPathSegment)].join("/")] = value;
|
||||
}
|
||||
return { newState: await this.updateInboxMetadata(id, update) };
|
||||
}
|
||||
|
||||
/** One Mailbox/set of the Inbox's metadata; answers the new Mailbox state. */
|
||||
private async updateInboxMetadata(id: string, update: Record<string, unknown>, options?: { private?: boolean }): Promise<string | undefined> {
|
||||
let inboxId = this.metadataInboxIds.get(id);
|
||||
if (!inboxId) {
|
||||
await this.getBulwarkMetadata(id, options);
|
||||
@@ -7167,7 +7204,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (!inboxId) throw new MetadataUnsupportedError();
|
||||
}
|
||||
const response = await this.request([
|
||||
["Mailbox/set", { accountId: id, update: { [inboxId]: { [`${property}/${namespaceFor(key)}`]: value } } }, "0"],
|
||||
["Mailbox/set", { accountId: id, update: { [inboxId]: update } }, "0"],
|
||||
], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", METADATA_CAPABILITY]);
|
||||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||||
if (method !== "Mailbox/set") {
|
||||
@@ -7176,8 +7213,11 @@ export class JMAPClient implements IJMAPClient {
|
||||
const notUpdated = (result as { notUpdated?: Record<string, { type?: string; description?: string }> }).notUpdated?.[inboxId];
|
||||
if (notUpdated) {
|
||||
if (/exceeds the maximum/i.test(notUpdated.description ?? "")) throw new MetadataTooLargeError(notUpdated.description!);
|
||||
if (notUpdated.type === "invalidPatch") throw new MetadataPatchError(notUpdated.description || "A parent of the patched value does not exist");
|
||||
throw new MetadataRefusedError(notUpdated.type ?? "unknown", notUpdated.description || `Failed to save metadata (${notUpdated.type ?? "unknown"})`);
|
||||
}
|
||||
const newState = (result as { newState?: unknown }).newState;
|
||||
return typeof newState === "string" ? newState : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -268,6 +268,8 @@ export interface MailboxRights {
|
||||
mayDelete: boolean;
|
||||
maySubmit: boolean;
|
||||
mayShare?: boolean;
|
||||
/** Stalwart 1.0: may change the mailbox's shared metadata (urn:ietf:params:jmap:metadata). */
|
||||
maySetMetadata?: boolean;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Shared tags of a group account: the tag names and colors every member of
|
||||
* a shared mailbox sees, kept in the metadata of the group's Inbox
|
||||
* (namespace `org.bulwarkmail.tags`, lib/jmap/bulwark-metadata.ts).
|
||||
*
|
||||
* Keywords in a group account are the same for every member
|
||||
* (local-data/org-features-facts.md §1), so `$label:urgent` on a team
|
||||
* message is the team's tag, not one member's. A personal definition only
|
||||
* names it for the member who made it; the shared definitions name it for
|
||||
* everyone.
|
||||
*
|
||||
* Stored as a map by tag id, `{ "tags": { "<id>": { "label", "color", ... } } }`,
|
||||
* so a change patches one tag (`metadata/org.bulwarkmail.tags/tags/<id>`)
|
||||
* and two members adding different tags at the same time both keep theirs.
|
||||
* Fields this version doesn't know are kept. Earlier shapes (`{ "list": [...] }`
|
||||
* or a bare array) are read, and converted once by the first save.
|
||||
*
|
||||
* Pure functions only.
|
||||
*/
|
||||
|
||||
import {
|
||||
FALLBACK_KEYWORD_COLOR,
|
||||
KEYWORD_PALETTE,
|
||||
type KeywordDefinition,
|
||||
type KeywordVisibility,
|
||||
} from '@/stores/settings-store';
|
||||
import { findUnrecognizedKeywords } from './keyword-discovery';
|
||||
import { MAX_KEYWORD_ID_LENGTH } from './keyword-nesting';
|
||||
import { MAX_METADATA_ENTRY_BYTES, MetadataTooLargeError, metadataValueBytes } from './jmap/bulwark-metadata';
|
||||
|
||||
/** The metadata key (namespace `org.bulwarkmail.tags`). */
|
||||
export const SHARED_TAGS_KEY = 'tags';
|
||||
|
||||
/** Longest name a shared tag keeps, as in the personal tag editor. */
|
||||
export const MAX_SHARED_TAG_LABEL = 30;
|
||||
|
||||
const VISIBILITIES: readonly KeywordVisibility[] = ['show', 'hide', 'unread'];
|
||||
|
||||
/**
|
||||
* 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(){\]%*"\\]/;
|
||||
|
||||
/** Whether a tag id from the server can be a keyword (no control characters either). */
|
||||
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);
|
||||
|
||||
/** What the server holds, as read. */
|
||||
export interface StoredSharedTags {
|
||||
/** The raw entry of each tag by id, unknown fields included. */
|
||||
entries: Record<string, Record<string, unknown>>;
|
||||
/** Other values of the namespace next to `tags`, kept as they are. */
|
||||
extra: Record<string, unknown>;
|
||||
/** The map shape is on the server, so changes can patch one tag. */
|
||||
patchable: boolean;
|
||||
}
|
||||
|
||||
export const EMPTY_SHARED_TAGS: StoredSharedTags = { entries: {}, extra: {}, patchable: false };
|
||||
|
||||
/**
|
||||
* Reads the namespace value: the map shape, or the earlier `{ list }` and
|
||||
* bare-array shapes (converted to entries; `patchable` stays false so the
|
||||
* first save writes the map shape).
|
||||
*/
|
||||
export function readSharedTags(value: unknown): StoredSharedTags {
|
||||
if (isObject(value) && isObject(value.tags)) {
|
||||
const entries: Record<string, Record<string, unknown>> = {};
|
||||
for (const [id, entry] of Object.entries(value.tags)) {
|
||||
if (isKeywordSafe(id) && isObject(entry)) entries[id] = entry;
|
||||
}
|
||||
const { tags: _tags, ...extra } = value;
|
||||
void _tags;
|
||||
return { entries, extra, patchable: true };
|
||||
}
|
||||
const items = Array.isArray(value) ? value : isObject(value) && Array.isArray(value.list) ? value.list : [];
|
||||
const entries: Record<string, Record<string, unknown>> = {};
|
||||
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());
|
||||
const { id: _id, ...rest } = item;
|
||||
void _id;
|
||||
entries[id] = rest;
|
||||
}
|
||||
return { entries, extra: {}, patchable: false };
|
||||
}
|
||||
|
||||
/** One tag as the app shows it; an unknown color shows grey but stays stored. */
|
||||
function definitionOf(id: string, entry: Record<string, unknown>): KeywordDefinition {
|
||||
const label = typeof entry.label === 'string' ? entry.label.trim().slice(0, MAX_SHARED_TAG_LABEL) : '';
|
||||
const color = typeof entry.color === 'string' && KEYWORD_PALETTE[entry.color] ? entry.color : FALLBACK_KEYWORD_COLOR;
|
||||
const definition: KeywordDefinition = { id, label: label || id, color };
|
||||
if (typeof entry.visibility === 'string' && (VISIBILITIES as readonly string[]).includes(entry.visibility)) {
|
||||
definition.visibility = entry.visibility as KeywordVisibility;
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
/** The shared definitions, by name (a map has no order of its own). */
|
||||
export function sharedDefinitions(stored: StoredSharedTags): KeywordDefinition[] {
|
||||
return Object.entries(stored.entries)
|
||||
.map(([id, entry]) => definitionOf(id, entry))
|
||||
.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** The shared definitions in a metadata value (any shape). */
|
||||
export function parseSharedTags(value: unknown): KeywordDefinition[] {
|
||||
return sharedDefinitions(readSharedTags(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* One change: a tag's new name and, when the member picked one, its color;
|
||||
* null deletes the tag. Other fields of the entry are kept.
|
||||
*/
|
||||
export interface SharedTagChange {
|
||||
id: string;
|
||||
value: { label: string; color?: string } | null;
|
||||
}
|
||||
|
||||
/** The id under which `id` is stored, in any case, or `id` itself. */
|
||||
function storedId(entries: Record<string, unknown>, id: string): string {
|
||||
const folded = id.toLowerCase();
|
||||
return Object.keys(entries).find((key) => key.toLowerCase() === folded) ?? id;
|
||||
}
|
||||
|
||||
/** The stored tags with the changes applied, nothing else touched. */
|
||||
export function applySharedTagChanges(stored: StoredSharedTags, changes: SharedTagChange[]): StoredSharedTags {
|
||||
const entries = { ...stored.entries };
|
||||
for (const change of changes) {
|
||||
const id = storedId(entries, change.id);
|
||||
if (change.value === null) {
|
||||
delete entries[id];
|
||||
continue;
|
||||
}
|
||||
const { label, color } = change.value;
|
||||
entries[id] = { ...(entries[id] ?? {}), label, ...(color !== undefined ? { color } : {}) };
|
||||
}
|
||||
return { ...stored, entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Mailbox/set patches for the changes, and what the server holds after
|
||||
* them. On the map shape each changed tag is one patch and no other tag is
|
||||
* written; on an older shape (or none) the whole namespace is written once
|
||||
* in the map shape. Throws MetadataTooLargeError when the result would take
|
||||
* more than the 8 KB of one namespace (UTF-8 bytes of its JSON).
|
||||
*/
|
||||
export function planSharedTagsSave(
|
||||
stored: StoredSharedTags,
|
||||
changes: SharedTagChange[],
|
||||
): { patches: Array<{ path: string[]; value: unknown }>; result: StoredSharedTags } {
|
||||
const next = applySharedTagChanges(stored, changes);
|
||||
const namespace = { ...next.extra, tags: next.entries };
|
||||
if (metadataValueBytes(namespace) > MAX_METADATA_ENTRY_BYTES) {
|
||||
throw new MetadataTooLargeError(`The shared tags take more than ${MAX_METADATA_ENTRY_BYTES} bytes`);
|
||||
}
|
||||
if (!stored.patchable) {
|
||||
return { patches: [{ path: [], value: namespace }], result: { ...next, patchable: true } };
|
||||
}
|
||||
const ids = [...new Set(changes.map((change) => storedId(stored.entries, change.id)))];
|
||||
return {
|
||||
patches: ids.map((id) => ({ path: ['tags', id], value: next.entries[id] ?? null })),
|
||||
result: next,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What a group account shows as its tags: the shared definitions, then
|
||||
* every tag found on its messages that no definition names, with the name
|
||||
* and color keyword discovery proposes for it. A keyword that is on a
|
||||
* message is never hidden because nobody defined it.
|
||||
*/
|
||||
export function groupAccountTags(
|
||||
shared: KeywordDefinition[],
|
||||
discovered: Record<string, number>,
|
||||
nested: boolean,
|
||||
): { definitions: KeywordDefinition[]; discoveredIds: string[] } {
|
||||
const found = findUnrecognizedKeywords(discovered, shared, nested);
|
||||
return {
|
||||
definitions: [...shared, ...found.map(({ id, label, color }) => ({ id, label, color }))],
|
||||
discoveredIds: found.map((entry) => entry.id),
|
||||
};
|
||||
}
|
||||
@@ -326,6 +326,34 @@
|
||||
"cancel_and_compose_again": "Abbrechen und neu verfassen",
|
||||
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30"
|
||||
},
|
||||
"shared_tags": {
|
||||
"title": "Geteilte Labels",
|
||||
"description": "Labels Ihrer Gruppenpostfächer. Alle in der Gruppe sehen dieselben Namen und Farben an ihren Nachrichten.",
|
||||
"picker_heading": "Mit allen in diesem Postfach geteilt",
|
||||
"sidebar_heading": "Labels",
|
||||
"edit": "Geteilte Labels bearbeiten",
|
||||
"edit_tag": "{name} bearbeiten",
|
||||
"delete_tag": "{name} löschen",
|
||||
"name_it": "{name} benennen",
|
||||
"add": "Geteiltes Label hinzufügen",
|
||||
"name": "Name",
|
||||
"color": "Farbe",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"delete_confirm": "Für alle löschen? Das Label bleibt an seinen Nachrichten.",
|
||||
"found_on_messages": "An Nachrichten, noch ohne Namen",
|
||||
"empty": "Noch keine geteilten Labels.",
|
||||
"exists": "Dieses Postfach hat dieses Label schon.",
|
||||
"too_long": "Dieser Name ist zu lang.",
|
||||
"loading": "Wird geladen…",
|
||||
"unsupported": "Dieser Mailserver kann keine geteilten Labels speichern, daher zeigt dieses Postfach Ihre eigenen Labels.",
|
||||
"unavailable": "Die geteilten Labels dieses Postfachs konnten nicht geladen werden, daher zeigt es Ihre eigenen Labels. Bulwark versucht es später erneut.",
|
||||
"no_rights": "Sie können diese Labels verwenden, aber in diesem Postfach nicht ändern.",
|
||||
"too_many": "Es gibt zu viele geteilte Labels. Löschen Sie einige, bevor Sie weitere hinzufügen.",
|
||||
"save_failed": "Die geteilten Labels konnten nicht gespeichert werden. Bulwark versucht es erneut, sobald der Server antwortet.",
|
||||
"save_pending": "Noch nicht gespeichert. Bulwark versucht es erneut, sobald der Server antwortet."
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "Der Absender bittet um eine Lesebestätigung:",
|
||||
|
||||
@@ -326,6 +326,34 @@
|
||||
"cancel_and_compose_again": "Cancel and compose again",
|
||||
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30"
|
||||
},
|
||||
"shared_tags": {
|
||||
"title": "Shared tags",
|
||||
"description": "Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.",
|
||||
"picker_heading": "Shared with everyone in this mailbox",
|
||||
"sidebar_heading": "Tags",
|
||||
"edit": "Edit shared tags",
|
||||
"edit_tag": "Edit {name}",
|
||||
"delete_tag": "Delete {name}",
|
||||
"name_it": "Name {name}",
|
||||
"add": "Add shared tag",
|
||||
"name": "Name",
|
||||
"color": "Color",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"delete_confirm": "Delete for everyone? The tag stays on its messages.",
|
||||
"found_on_messages": "On messages, no name yet",
|
||||
"empty": "No shared tags yet.",
|
||||
"exists": "This mailbox already has this tag.",
|
||||
"too_long": "This name is too long.",
|
||||
"loading": "Loading…",
|
||||
"unsupported": "This mail server can't keep shared tags, so this mailbox shows your own tags.",
|
||||
"unavailable": "Couldn't load the shared tags of this mailbox, so it shows your own tags. Bulwark tries again later.",
|
||||
"no_rights": "You can use these tags, but you can't change them in this mailbox.",
|
||||
"too_many": "There are too many shared tags. Delete some before adding more.",
|
||||
"save_failed": "Couldn't save the shared tags. Bulwark tries again when the server answers.",
|
||||
"save_pending": "Not saved yet. Bulwark tries again when the server answers."
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "The sender asked to be notified when you open this message.",
|
||||
|
||||
@@ -112,6 +112,21 @@ describe('tag view across the own and group accounts (#1038)', () => {
|
||||
expect(client.getEmails).toHaveBeenCalledWith(undefined, 'group', 50, 0, keyword, true, undefined, []);
|
||||
});
|
||||
|
||||
it('lists the group account only for a tag picked from its shared tags', async () => {
|
||||
useEmailStore.setState({ selectedMailbox: ownInbox.id });
|
||||
useEmailStore.getState().selectKeyword(label, 'group');
|
||||
|
||||
await useEmailStore.getState().fetchEmails(client);
|
||||
|
||||
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['grp-1']);
|
||||
expect(client.getEmails).toHaveBeenCalledTimes(1);
|
||||
expect(client.getEmails).toHaveBeenCalledWith(undefined, 'group', 50, 0, keyword, true, undefined, []);
|
||||
|
||||
// A tag from the own list spans every account again.
|
||||
useEmailStore.getState().selectKeyword(label);
|
||||
expect(useEmailStore.getState().selectedKeywordAccountId).toBeNull();
|
||||
});
|
||||
|
||||
it('lists the same set while an own folder is selected', async () => {
|
||||
useEmailStore.setState({ selectedMailbox: ownInbox.id });
|
||||
useEmailStore.getState().selectKeyword(label);
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { MAX_METADATA_ENTRY_BYTES, MetadataPatchError, MetadataTooLargeError, metadataValueBytes } from '@/lib/jmap/bulwark-metadata';
|
||||
import { emitPushStateChange } from '@/lib/push-events';
|
||||
import {
|
||||
DISCOVERY_INTERVAL_MS,
|
||||
GROUP_CHECK_RETRY_MS,
|
||||
PUSH_DEBOUNCE_MS,
|
||||
STALE_AFTER_MS,
|
||||
SharedTagsSaveDeferredError,
|
||||
loadSharedTags,
|
||||
resetSharedTags,
|
||||
saveSharedTags,
|
||||
sharedTagsKey,
|
||||
useSharedTagsStore,
|
||||
type SharedTagsClient,
|
||||
} from '../shared-tags-store';
|
||||
|
||||
const KEY = sharedTagsKey('login-1', 'team');
|
||||
const URGENT = { label: 'Urgent', color: 'red' };
|
||||
|
||||
/** org-team's Inbox metadata on a v1.0.0 server, shared by every member's client. */
|
||||
class FakeServer {
|
||||
namespace: Record<string, unknown> | null = { tags: { urgent: { ...URGENT } } };
|
||||
state = 1;
|
||||
failReads = false;
|
||||
failWrites = false;
|
||||
|
||||
patch(patches: Array<{ path: string[]; value: unknown }>): { newState: string } {
|
||||
if (this.failWrites) throw new TypeError('Failed to fetch');
|
||||
let next = this.namespace === null ? null : structuredClone(this.namespace);
|
||||
for (const { path, value } of patches) {
|
||||
if (path.length === 0) { next = value as Record<string, unknown> | null; continue; }
|
||||
if (!next) throw new MetadataPatchError('The namespace does not exist.');
|
||||
let parent: Record<string, unknown> = next;
|
||||
for (const segment of path.slice(0, -1)) {
|
||||
const child = parent[segment];
|
||||
if (!child || typeof child !== 'object' || Array.isArray(child)) throw new MetadataPatchError('A parent of the patched value does not exist or is not an object.');
|
||||
parent = child as Record<string, unknown>;
|
||||
}
|
||||
const last = path[path.length - 1];
|
||||
if (value === null) delete parent[last];
|
||||
else parent[last] = value;
|
||||
}
|
||||
if (next && metadataValueBytes(next) > MAX_METADATA_ENTRY_BYTES) throw new MetadataTooLargeError('exceeds the maximum');
|
||||
this.namespace = next;
|
||||
this.state += 1;
|
||||
return { newState: `s${this.state}` };
|
||||
}
|
||||
}
|
||||
|
||||
function member(server: FakeServer, options: { type?: string | null; advertised?: boolean; keywords?: Record<string, number> } = {}) {
|
||||
let paused = false;
|
||||
const client = {
|
||||
getAccountInfo: vi.fn(() => ({ isPersonal: false })),
|
||||
getPrincipalTypes: vi.fn(async (ids: string[]) =>
|
||||
options.type === null ? null : Object.fromEntries(ids.map((id) => [id, options.type ?? 'group']))),
|
||||
advertisesBulwarkMetadata: vi.fn(() => options.advertised !== false),
|
||||
supportsBulwarkMetadata: vi.fn(() => options.advertised !== false && !paused),
|
||||
getBulwarkMetadata: vi.fn(async () => {
|
||||
if (server.failReads) { paused = true; return null; }
|
||||
return server.namespace ? { tags: structuredClone(server.namespace) } : {};
|
||||
}),
|
||||
patchBulwarkMetadata: vi.fn(async (_id: string | undefined, _key: string, patches: Array<{ path: string[]; value: unknown }>) => server.patch(patches)),
|
||||
discoverKeywords: vi.fn(async () => ({ keywords: options.keywords ?? {}, scanned: 0, total: 0, complete: true })),
|
||||
endPause: () => { paused = false; },
|
||||
};
|
||||
return client as typeof client & SharedTagsClient;
|
||||
}
|
||||
|
||||
const entry = (key = KEY) => useSharedTagsStore.getState().entries[key];
|
||||
const push = (client: SharedTagsClient, state: string, account = 'team') =>
|
||||
emitPushStateChange({ '@type': 'StateChange', changed: { [account]: { Mailbox: state } } }, client as never, 'active');
|
||||
function becomeVisible() {
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}
|
||||
|
||||
let server: FakeServer;
|
||||
beforeEach(() => { resetSharedTags(); server = new FakeServer(); });
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); });
|
||||
|
||||
describe('loading', () => {
|
||||
it('reads the shared definitions of a confirmed group and scans its messages', async () => {
|
||||
const client = member(server, { keywords: { '$label:found': 3, $seen: 9 } });
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
expect(client.getBulwarkMetadata).toHaveBeenCalledWith('team');
|
||||
expect(entry()).toMatchObject({ status: 'ready', tags: [{ id: 'urgent', ...URGENT }] });
|
||||
expect(client.discoverKeywords).toHaveBeenCalledWith(expect.objectContaining({ accountId: 'team' }));
|
||||
expect(entry().discovered).toEqual({ '$label:found': 3 });
|
||||
});
|
||||
|
||||
it('leaves a mailbox a person shared to the user\'s own tags, without asking for metadata', async () => {
|
||||
const client = member(server, { type: 'individual' });
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
expect(entry().status).toBe('personal');
|
||||
expect(client.getBulwarkMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never guesses a group when the principal lookup fails, and asks again later', async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = member(server, { type: null });
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
expect(entry().status).toBe('loading');
|
||||
expect(client.getBulwarkMetadata).not.toHaveBeenCalled();
|
||||
|
||||
client.getPrincipalTypes.mockImplementation(async (ids: string[]) => Object.fromEntries(ids.map((id) => [id, 'group'])));
|
||||
await vi.advanceTimersByTimeAsync(GROUP_CHECK_RETRY_MS);
|
||||
await vi.waitFor(() => expect(entry().status).toBe('ready'));
|
||||
});
|
||||
|
||||
it('sends no metadata request where the server advertises none (0.16)', async () => {
|
||||
const client = member(server, { advertised: false });
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
expect(entry().status).toBe('unsupported');
|
||||
expect(client.getBulwarkMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tells a failed read apart from no metadata, and reads again after the client\'s pause', async () => {
|
||||
server.failReads = true;
|
||||
const client = member(server);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
expect(entry().status).toBe('unavailable');
|
||||
|
||||
// Still paused: coming back to the tab asks nothing.
|
||||
server.failReads = false;
|
||||
becomeVisible();
|
||||
await Promise.resolve();
|
||||
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1);
|
||||
|
||||
client.endPause();
|
||||
becomeVisible();
|
||||
await vi.waitFor(() => expect(entry().status).toBe('ready'));
|
||||
});
|
||||
|
||||
it('keeps the last good tags when a later read fails', async () => {
|
||||
const client = member(server);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
server.failReads = true;
|
||||
await loadSharedTags(KEY, 'team', client, { force: true });
|
||||
expect(entry()).toMatchObject({ status: 'ready', tags: [{ id: 'urgent', ...URGENT }] });
|
||||
expect(entry().readFailedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('loads once per client and from scratch for a new sign-in', async () => {
|
||||
const client = member(server);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1);
|
||||
server.namespace = { tags: {} };
|
||||
await loadSharedTags(KEY, 'team', member(server));
|
||||
expect(entry().tags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshing', () => {
|
||||
it('reads again once push has been quiet for 5 s, once for a burst', async () => {
|
||||
const client = member(server);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
vi.useFakeTimers();
|
||||
server.patch([{ path: ['tags', 'later'], value: { label: 'Later', color: 'blue' } }]);
|
||||
|
||||
// Another account of the login doesn't concern it.
|
||||
push(client, 'x9', 'own');
|
||||
push(client, 's2');
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
push(client, 's3');
|
||||
await vi.advanceTimersByTimeAsync(PUSH_DEBOUNCE_MS - 1);
|
||||
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await vi.waitFor(() => expect(entry().tags.map((t) => t.id)).toEqual(['later', 'urgent']));
|
||||
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('skips the push of its own save', async () => {
|
||||
const client = member(server);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
await saveSharedTags(KEY, 'team', client, [{ id: 'later', value: { label: 'Later', color: 'blue' } }]);
|
||||
vi.useFakeTimers();
|
||||
push(client, `s${server.state}`);
|
||||
await vi.advanceTimersByTimeAsync(PUSH_DEBOUNCE_MS * 2);
|
||||
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reloads only the definitions when the tab comes back, and scans the messages at most every 30 minutes', async () => {
|
||||
let now = 1_000_000;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const client = member(server);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
expect(client.discoverKeywords).toHaveBeenCalledTimes(1);
|
||||
|
||||
now += STALE_AFTER_MS;
|
||||
becomeVisible();
|
||||
await vi.waitFor(() => expect(entry().loadedAt).toBe(now));
|
||||
// Let that load finish before the tab comes back again.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(client.getBulwarkMetadata).toHaveBeenCalledTimes(2);
|
||||
expect(client.discoverKeywords).toHaveBeenCalledTimes(1);
|
||||
|
||||
now += DISCOVERY_INTERVAL_MS;
|
||||
becomeVisible();
|
||||
await vi.waitFor(() => expect(client.discoverKeywords).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('saving', () => {
|
||||
it('keeps the tags two members add at the same time', async () => {
|
||||
const alice = member(server);
|
||||
const bob = member(server);
|
||||
const bobKey = sharedTagsKey('login-2', 'team');
|
||||
await loadSharedTags(KEY, 'team', alice);
|
||||
await loadSharedTags(bobKey, 'team', bob);
|
||||
await Promise.all([
|
||||
saveSharedTags(KEY, 'team', alice, [{ id: 'alice-tag', value: { label: 'Alice', color: 'green' } }]),
|
||||
saveSharedTags(bobKey, 'team', bob, [{ id: 'bob-tag', value: { label: 'Bob', color: 'blue' } }]),
|
||||
]);
|
||||
expect(Object.keys((server.namespace as { tags: object }).tags).sort()).toEqual(['alice-tag', 'bob-tag', 'urgent']);
|
||||
});
|
||||
|
||||
it('leaves a color another client set alone when it edits something else, or renames that tag', async () => {
|
||||
server.namespace = { tags: { urgent: { ...URGENT }, other: { label: 'Other', color: 'chartreuse', icon: 'flame' } } };
|
||||
const client = member(server);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
await saveSharedTags(KEY, 'team', client, [{ id: 'urgent', value: { label: 'Very urgent', color: 'red-dark' } }]);
|
||||
await saveSharedTags(KEY, 'team', client, [{ id: 'other', value: { label: 'Renamed' } }]);
|
||||
expect(server.namespace).toEqual({ tags: {
|
||||
urgent: { label: 'Very urgent', color: 'red-dark' },
|
||||
other: { label: 'Renamed', color: 'chartreuse', icon: 'flame' },
|
||||
} });
|
||||
expect(client.patchBulwarkMetadata.mock.calls.map((c) => c[2])).toEqual([
|
||||
[{ path: ['tags', 'urgent'], value: { label: 'Very urgent', color: 'red-dark' } }],
|
||||
[{ path: ['tags', 'other'], value: { label: 'Renamed', color: 'chartreuse', icon: 'flame' } }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts the earlier list shape once, then patches', async () => {
|
||||
server.namespace = { list: [{ id: 'urgent', ...URGENT }] };
|
||||
const client = member(server);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
await saveSharedTags(KEY, 'team', client, [{ id: 'later', value: { label: 'Later', color: 'blue' } }]);
|
||||
expect(server.namespace).toEqual({ tags: { urgent: URGENT, later: { label: 'Later', color: 'blue' } } });
|
||||
await saveSharedTags(KEY, 'team', client, [{ id: 'later', value: null }]);
|
||||
expect(client.patchBulwarkMetadata.mock.calls[1][2]).toEqual([{ path: ['tags', 'later'], value: null }]);
|
||||
expect(server.namespace).toEqual({ tags: { urgent: URGENT } });
|
||||
});
|
||||
|
||||
it('creates the namespace when there is none, keeping a tag another member just created it with', async () => {
|
||||
server.namespace = null;
|
||||
const client = member(server);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
await saveSharedTags(KEY, 'team', client, [{ id: 'first', value: { label: 'First', color: 'blue' } }]);
|
||||
expect(server.namespace).toEqual({ tags: { first: { label: 'First', color: 'blue' } } });
|
||||
|
||||
// Bob read nothing, Alice created it meanwhile: Bob's patch lands next to hers.
|
||||
server.namespace = null;
|
||||
const bob = member(server);
|
||||
const bobKey = sharedTagsKey('login-2', 'team');
|
||||
await loadSharedTags(bobKey, 'team', bob);
|
||||
server.namespace = { tags: { alice: { label: 'Alice', color: 'green' } } };
|
||||
await saveSharedTags(bobKey, 'team', bob, [{ id: 'bob', value: { label: 'Bob', color: 'red' } }]);
|
||||
expect(Object.keys((server.namespace as { tags: object }).tags).sort()).toEqual(['alice', 'bob']);
|
||||
});
|
||||
|
||||
it('refuses a list that no longer fits and sends nothing', async () => {
|
||||
const client = member(server);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
const many = Array.from({ length: 200 }, (_, i) => ({ id: `tag-number-${i}`, value: { label: `Tag number ${i}`, color: 'blue' } }));
|
||||
await expect(saveSharedTags(KEY, 'team', client, many)).rejects.toBeInstanceOf(MetadataTooLargeError);
|
||||
expect(client.patchBulwarkMetadata).not.toHaveBeenCalled();
|
||||
expect(entry().pending).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a change the server didn\'t take, shows it, and sends it with the next good read', async () => {
|
||||
const client = member(server);
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
server.failWrites = true;
|
||||
await expect(saveSharedTags(KEY, 'team', client, [{ id: 'later', value: { label: 'Later', color: 'blue' } }]))
|
||||
.rejects.toBeInstanceOf(SharedTagsSaveDeferredError);
|
||||
expect(entry().pending).toHaveLength(1);
|
||||
expect(entry().tags.map((t) => t.id)).toEqual(['later', 'urgent']);
|
||||
|
||||
server.failWrites = false;
|
||||
becomeVisible();
|
||||
await vi.waitFor(() => expect(entry().pending).toEqual([]));
|
||||
expect((server.namespace as { tags: object }).tags).toHaveProperty('later');
|
||||
});
|
||||
});
|
||||
|
||||
// Through the real JMAP client and recorded Stalwart v1.0.0 answers.
|
||||
describe('with the JMAP client', () => {
|
||||
const METADATA_CAP = {
|
||||
'urn:ietf:params:jmap:metadata': {
|
||||
dataTypes: { Mailbox: { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: 8 } },
|
||||
},
|
||||
};
|
||||
const session = {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:principals': {}, 'urn:ietf:params:jmap:metadata': {} },
|
||||
accounts: {
|
||||
me: { name: 'org-alice@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...METADATA_CAP } },
|
||||
team: { name: 'org-team@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:principals': {}, ...METADATA_CAP } },
|
||||
},
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'me', 'urn:ietf:params:jmap:principals': 'me' },
|
||||
apiUrl: 'https://mail.example.com/jmap/api',
|
||||
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
||||
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
||||
eventSourceUrl: '',
|
||||
};
|
||||
const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
type Body = { using: string[]; methodCalls: Array<[string, Record<string, unknown>, string]> };
|
||||
|
||||
async function connected(answer: (body: Body) => Response | Promise<Response>) {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValueOnce(json(session));
|
||||
const client = new JMAPClient('https://mail.example.com', 'org-alice@example.org', 'pw');
|
||||
await client.connect();
|
||||
fetchSpy.mockReset();
|
||||
const requests: Body[] = [];
|
||||
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as Body;
|
||||
requests.push(body);
|
||||
return answer(body);
|
||||
});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
return { client, requests };
|
||||
}
|
||||
|
||||
const answers = (body: Body, inbox: Record<string, unknown>) => {
|
||||
const method = body.methodCalls[0][0];
|
||||
if (method === 'Principal/get') return json({ methodResponses: [['Principal/get', { list: [{ id: 'team', type: 'group' }] }, '0']] });
|
||||
if (method === 'Mailbox/query') {
|
||||
return json({ methodResponses: [['Mailbox/query', { ids: ['a'] }, 'q'], ['Mailbox/get', { list: [{ id: 'a', ...inbox }] }, 'g']] });
|
||||
}
|
||||
if (method === 'Mailbox/set') return json({ methodResponses: [['Mailbox/set', { oldState: 's1', newState: 's2', updated: { a: null } }, '0']] });
|
||||
if (method === 'Email/query') return json({ methodResponses: [['Email/query', { ids: [], total: 0 }, '0'], ['Email/get', { list: [] }, '1']] });
|
||||
throw new Error(`unexpected ${method}`);
|
||||
};
|
||||
|
||||
it('reads org.bulwarkmail.tags of the group Inbox and patches one tag, its id escaped', async () => {
|
||||
const { client, requests } = await connected((body) => answers(body, { metadata: { 'org.bulwarkmail.tags': { tags: { urgent: URGENT } }, 'org.bulwarkmail.template.x': {} } }));
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
expect(entry()).toMatchObject({ status: 'ready', tags: [{ id: 'urgent', ...URGENT }] });
|
||||
// Tags nobody named are looked for in the group account, not the own one.
|
||||
const scan = requests.find((r) => r.methodCalls[0][0] === 'Email/query')!;
|
||||
expect(scan.methodCalls.map((c) => c[1].accountId)).toEqual(['team', 'team']);
|
||||
|
||||
await saveSharedTags(KEY, 'team', client, [{ id: 'work/clients', value: { label: 'Clients', color: 'blue' } }]);
|
||||
const set = requests.find((r) => r.methodCalls[0][0] === 'Mailbox/set')!;
|
||||
expect(set.methodCalls[0][1]).toEqual({
|
||||
accountId: 'team',
|
||||
update: { a: { 'metadata/org.bulwarkmail.tags/tags/work~1clients': { label: 'Clients', color: 'blue' } } },
|
||||
});
|
||||
});
|
||||
|
||||
it('counts a dropped metadata request as unavailable, not as no metadata', async () => {
|
||||
const { client } = await connected((body) => {
|
||||
if (body.methodCalls[0][0] === 'Mailbox/query') throw new TypeError('Failed to fetch');
|
||||
return answers(body, {});
|
||||
});
|
||||
await loadSharedTags(KEY, 'team', client);
|
||||
expect(entry().status).toBe('unavailable');
|
||||
});
|
||||
});
|
||||
+34
-12
@@ -192,6 +192,12 @@ interface EmailStore {
|
||||
|
||||
// Keyword/tag filter
|
||||
selectedKeyword: string | null;
|
||||
/**
|
||||
* A tag picked from a group account's shared tags in the sidebar: the tag
|
||||
* view lists that JMAP account (of the active login) only. Null: every
|
||||
* account the login reaches (#1038).
|
||||
*/
|
||||
selectedKeywordAccountId: string | null;
|
||||
tagCounts: Record<string, { total: number; unread: number }>;
|
||||
|
||||
// Advanced search state
|
||||
@@ -303,7 +309,7 @@ interface EmailStore {
|
||||
setError: (error: string | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setQuota: (quota: StorageQuota | null) => void;
|
||||
selectKeyword: (keyword: string | null) => void;
|
||||
selectKeyword: (keyword: string | null, accountId?: string | null) => void;
|
||||
fetchTagCounts: (client: IJMAPClient) => Promise<void>;
|
||||
/** Take the email, not its id: the id alone collides across accounts. */
|
||||
toggleEmailSelection: (email: SelectableEmail) => void;
|
||||
@@ -1843,6 +1849,16 @@ export function buildTagViewAccountClients(passedClient: IJMAPClient): UnifiedAc
|
||||
return built;
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts a tag view lists: all of them, or only the group account
|
||||
* whose shared tag was picked in the sidebar (selectedKeywordAccountId).
|
||||
*/
|
||||
function tagViewAccounts(passedClient: IJMAPClient): UnifiedAccountClient[] {
|
||||
const built = buildTagViewAccountClients(passedClient);
|
||||
const scoped = useEmailStore.getState().selectedKeywordAccountId;
|
||||
return scoped ? built.filter((account) => account.jmapAccountId === scoped) : built;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a search is running under a folder-less scope (the default "All
|
||||
* folders except Spam and Trash" or "All folders", see
|
||||
@@ -3096,6 +3112,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
// Keyword/tag filter
|
||||
selectedKeyword: null,
|
||||
selectedKeywordAccountId: null,
|
||||
tagCounts: {},
|
||||
|
||||
// Advanced search state
|
||||
@@ -3161,6 +3178,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
selectedEmail: null,
|
||||
selectedEmailKeys: new Set(),
|
||||
selectedKeyword: null,
|
||||
selectedKeywordAccountId: null,
|
||||
expandedThreadIds: new Set(),
|
||||
threadEmailsCache: new Map(),
|
||||
threadEmailCounts: new Map(),
|
||||
@@ -3251,8 +3269,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
set({ listHold: { rowKey, keywords } });
|
||||
},
|
||||
selectKeyword: (keyword) => set(state => ({
|
||||
selectKeyword: (keyword, accountId = null) => set(state => ({
|
||||
selectedKeyword: keyword,
|
||||
selectedKeywordAccountId: keyword ? accountId : null,
|
||||
isLoadingMore: false,
|
||||
selectedEmail: null,
|
||||
selectedEmailKeys: new Set(),
|
||||
@@ -3309,6 +3328,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
selectedEmail: null,
|
||||
selectedEmailKeys: new Set(),
|
||||
selectedKeyword: null,
|
||||
selectedKeywordAccountId: null,
|
||||
expandedThreadIds: new Set(),
|
||||
threadEmailsCache: new Map(),
|
||||
threadEmailCounts: new Map(),
|
||||
@@ -3603,7 +3623,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const { selectedKeyword } = get();
|
||||
if (selectedKeyword) {
|
||||
const order = getMessageListOrderFor(null);
|
||||
const built = buildTagViewAccountClients(client);
|
||||
const built = tagViewAccounts(client);
|
||||
// Selecting a tag keeps an active search (`handleTagSelect` does not
|
||||
// clear it), and the search paths narrow the tag rather than replace
|
||||
// it — so honour the query here too, or the list would flip between
|
||||
@@ -3907,7 +3927,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Searching inside a tag view narrows the tag (see searchEmails), so
|
||||
// page 2 must carry the same keyword AND the same query — otherwise it
|
||||
// appends unrelated all-folders hits to a tag list.
|
||||
const built = buildTagViewAccountClients(client);
|
||||
const built = tagViewAccounts(client);
|
||||
result = await fetchTagEmails(
|
||||
built, `$label:${selectedKeyword}`, emailsPerPage, positionsByAccount(emails), getMessageListOrderFor(null),
|
||||
hasFilters
|
||||
@@ -3922,7 +3942,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
if (isAllFoldersSearchScope(searchMailboxId)) {
|
||||
// Folder-less scope: the next page of the same own + group account
|
||||
// fan-out the search ran (#1082).
|
||||
const built = buildTagViewAccountClients(client);
|
||||
const built = tagViewAccounts(client);
|
||||
const scope = acrossAccountsSearchOptions(searchMailboxId);
|
||||
result = hasFilters
|
||||
? await advancedSearchAcrossAccounts(built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, positionsByAccount(emails), scope)
|
||||
@@ -3945,7 +3965,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Tag view: the next page of the same cross-account fan-out the
|
||||
// initial fetch ran (same keyword, no folder constraint, same order),
|
||||
// so pagination spans the own and group accounts alike (#1038).
|
||||
const built = buildTagViewAccountClients(client);
|
||||
const built = tagViewAccounts(client);
|
||||
result = await fetchTagEmails(
|
||||
built, `$label:${selectedKeyword}`, emailsPerPage, positionsByAccount(emails), getMessageListOrderFor(null),
|
||||
);
|
||||
@@ -4979,7 +4999,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// NARROWS the tag rather than silently replacing the list with an
|
||||
// unrelated all-folders result that the next push refresh would throw
|
||||
// away again.
|
||||
const built = buildTagViewAccountClients(client);
|
||||
const built = tagViewAccounts(client);
|
||||
result = await fetchTagEmails(
|
||||
built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null),
|
||||
textFilter(query),
|
||||
@@ -4992,7 +5012,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// views use (#1038). A single own-account query silently skipped the
|
||||
// shared accounts' mail (#1082). The default scope leaves every
|
||||
// account's Trash and Junk out; "All folders" searches them too.
|
||||
const built = buildTagViewAccountClients(client);
|
||||
const built = tagViewAccounts(client);
|
||||
result = await searchAcrossAccounts(built, query, emailsPerPage, 0, { ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true });
|
||||
unifiedErrors = result.errors;
|
||||
|
||||
@@ -5094,7 +5114,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
} else if (selectedKeyword) {
|
||||
// Narrow the tag rather than replace it — see searchEmails.
|
||||
const built = buildTagViewAccountClients(client);
|
||||
const built = tagViewAccounts(client);
|
||||
result = await fetchTagEmails(
|
||||
built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null),
|
||||
buildJMAPFilter(searchQuery, searchFilters, undefined),
|
||||
@@ -5104,7 +5124,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
} else if (isAllFoldersSearchScope(searchMailboxId)) {
|
||||
// Folder-less scope: fan out over the own + group/shared accounts with
|
||||
// a filter that carries no inMailbox clause (#1082).
|
||||
const built = buildTagViewAccountClients(client);
|
||||
const built = tagViewAccounts(client);
|
||||
result = await advancedSearchAcrossAccounts(
|
||||
built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0,
|
||||
{ ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true },
|
||||
@@ -6501,7 +6521,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// They cannot establish a folder delta baseline.
|
||||
// An active search narrows the tag, so carry it or the refresh
|
||||
// would silently widen the list back to the whole tag.
|
||||
const built = buildTagViewAccountClients(client);
|
||||
const built = tagViewAccounts(client);
|
||||
result = await fetchTagEmails(
|
||||
built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null),
|
||||
hasFilters
|
||||
@@ -6518,7 +6538,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
if (isAllFoldersSearchScope(searchMailboxId)) {
|
||||
// Folder-less scope: the same own + group account fan-out the
|
||||
// search ran, so a change in a group account reaches the list (#1082).
|
||||
const built = buildTagViewAccountClients(client);
|
||||
const built = tagViewAccounts(client);
|
||||
result = await advancedSearchAcrossAccounts(
|
||||
built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0,
|
||||
acrossAccountsSearchOptions(searchMailboxId),
|
||||
@@ -7484,6 +7504,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
unifiedRole: role,
|
||||
crossView: null,
|
||||
selectedKeyword: null,
|
||||
selectedKeywordAccountId: null,
|
||||
retainedInViewIds: new Set(),
|
||||
listHold: null,
|
||||
viewToken: token,
|
||||
@@ -7570,6 +7591,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
unifiedRole: null,
|
||||
crossView: view,
|
||||
selectedKeyword: null,
|
||||
selectedKeywordAccountId: null,
|
||||
retainedInViewIds: new Set(),
|
||||
listHold: null,
|
||||
viewToken: token,
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* The shared tags of each group account a signed-in login reaches
|
||||
* (lib/shared-tags.ts): loaded the first time something on screen shows a
|
||||
* message or the sidebar of that account, kept per login and account.
|
||||
*
|
||||
* Refreshing: Stalwart bumps the Mailbox state of the group account when
|
||||
* its Inbox metadata changes, and every member's push channel reports the
|
||||
* state changes of the accounts it reaches. So another member's edit
|
||||
* arrives as a Mailbox change of the group account; reloads are debounced
|
||||
* per account (5 s, a Mailbox change also comes with new mail) and the push
|
||||
* of our own save is recognised by its state and skipped. Push can be off,
|
||||
* so a tab that comes back reads definitions older than two minutes again.
|
||||
* The scan for tags nobody named runs once per sign-in and then at most
|
||||
* every 30 minutes.
|
||||
*
|
||||
* Status per account:
|
||||
* - `loading`: not known yet, or the server wouldn't say whether the
|
||||
* account is a group (asked again later; never guessed, since members
|
||||
* write here). The user's own tags show meanwhile.
|
||||
* - `personal`: not a group principal (a mailbox a person shared).
|
||||
* - `unsupported`: a group, but the server advertises no metadata (0.16).
|
||||
* - `unavailable`: a group whose metadata couldn't be read yet (the 1.0
|
||||
* test build drops the request). Read again after the client's pause.
|
||||
* - `ready`: the shared definitions. A later failed read keeps them.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { MetadataPatchError, MetadataTooLargeError, MetadataUnsupportedError } from '@/lib/jmap/bulwark-metadata';
|
||||
import { tagIdFromKeyword } from '@/lib/keyword-discovery';
|
||||
import {
|
||||
EMPTY_SHARED_TAGS,
|
||||
SHARED_TAGS_KEY,
|
||||
applySharedTagChanges,
|
||||
planSharedTagsSave,
|
||||
readSharedTags,
|
||||
sharedDefinitions,
|
||||
type SharedTagChange,
|
||||
type StoredSharedTags,
|
||||
} from '@/lib/shared-tags';
|
||||
import { subscribePushStateChanges } from '@/lib/push-events';
|
||||
import type { KeywordDefinition } from '@/stores/settings-store';
|
||||
|
||||
export type SharedTagsStatus = 'loading' | 'personal' | 'unsupported' | 'unavailable' | 'ready';
|
||||
|
||||
export interface SharedTagsEntry {
|
||||
status: SharedTagsStatus;
|
||||
/** The shared definitions with unsaved changes applied (status `ready`). */
|
||||
tags: KeywordDefinition[];
|
||||
/** What the server holds, as last read or written. */
|
||||
stored: StoredSharedTags | null;
|
||||
/** Changes a failed save left; sent again with the next good read. */
|
||||
pending: SharedTagChange[];
|
||||
/** Tag keywords found on the account's recent messages, with counts. */
|
||||
discovered: Record<string, number>;
|
||||
loadedAt: number;
|
||||
discoveredAt: number;
|
||||
/** When the last read failed; null after a good one. */
|
||||
readFailedAt: number | null;
|
||||
}
|
||||
|
||||
export type SharedTagsClient = Pick<IJMAPClient, 'discoverKeywords'>
|
||||
& Partial<Pick<IJMAPClient, 'getPrincipalTypes' | 'supportsBulwarkMetadata' | 'advertisesBulwarkMetadata' | 'getBulwarkMetadata' | 'patchBulwarkMetadata'>>;
|
||||
|
||||
/** Messages scanned for tags nobody named: one request's worth. */
|
||||
export const DISCOVERY_SCAN_LIMIT = 500;
|
||||
/** A tab that comes back reads definitions older than this again. */
|
||||
export const STALE_AFTER_MS = 2 * 60_000;
|
||||
/** The scan for unnamed tags runs again after this, at the earliest. */
|
||||
export const DISCOVERY_INTERVAL_MS = 30 * 60_000;
|
||||
/** Push-triggered reloads of one account wait this long for quiet. */
|
||||
export const PUSH_DEBOUNCE_MS = 5_000;
|
||||
/** First retry when the server wouldn't say whether an account is a group. */
|
||||
export const GROUP_CHECK_RETRY_MS = 30_000;
|
||||
|
||||
/** A save that didn't reach the server; its changes are kept and sent again. */
|
||||
export class SharedTagsSaveDeferredError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super(cause instanceof Error ? cause.message : 'The shared tags could not be saved');
|
||||
this.name = 'SharedTagsSaveDeferredError';
|
||||
}
|
||||
}
|
||||
|
||||
interface SharedTagsState {
|
||||
entries: Record<string, SharedTagsEntry>;
|
||||
}
|
||||
|
||||
export const useSharedTagsStore = create<SharedTagsState>()(() => ({ entries: {} }));
|
||||
|
||||
export function sharedTagsKey(loginId: string, accountId: string): string {
|
||||
return `${loginId}|${accountId}`;
|
||||
}
|
||||
|
||||
const clients = new Map<string, SharedTagsClient>();
|
||||
const inflight = new Map<string, Promise<void>>();
|
||||
const accountIds = new Map<string, string>();
|
||||
const groupConfirmed = new Set<string>();
|
||||
const ownStates = new Map<string, Set<string>>();
|
||||
const pushTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const retryTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; delay: number }>();
|
||||
let unsubscribePush: (() => void) | null = null;
|
||||
let visibilityHandler: (() => void) | null = null;
|
||||
|
||||
const NEW_ENTRY: SharedTagsEntry = {
|
||||
status: 'loading', tags: [], stored: null, pending: [], discovered: {}, loadedAt: 0, discoveredAt: 0, readFailedAt: null,
|
||||
};
|
||||
|
||||
const entryOf = (key: string): SharedTagsEntry | undefined => useSharedTagsStore.getState().entries[key];
|
||||
|
||||
function setEntry(key: string, patch: Partial<SharedTagsEntry>): void {
|
||||
useSharedTagsStore.setState((state) => {
|
||||
const previous = state.entries[key] ?? NEW_ENTRY;
|
||||
const next = { ...previous, ...patch };
|
||||
if (next.stored) next.tags = sharedDefinitions(applySharedTagChanges(next.stored, next.pending));
|
||||
return { entries: { ...state.entries, [key]: next } };
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether the client's metadata pause is over for the account. */
|
||||
const mayAskMetadata = (client: SharedTagsClient, accountId: string) =>
|
||||
typeof client.supportsBulwarkMetadata === 'function' && client.supportsBulwarkMetadata(accountId);
|
||||
|
||||
function scheduleGroupRetry(key: string): void {
|
||||
const previous = retryTimers.get(key);
|
||||
if (previous) clearTimeout(previous.timer);
|
||||
const delay = previous ? Math.min(previous.delay * 2, 10 * 60_000) : GROUP_CHECK_RETRY_MS;
|
||||
const timer = setTimeout(() => {
|
||||
const client = clients.get(key);
|
||||
const accountId = accountIds.get(key);
|
||||
if (client && accountId && entryOf(key)?.status === 'loading') void loadSharedTags(key, accountId, client, { force: true });
|
||||
}, delay);
|
||||
retryTimers.set(key, { timer, delay });
|
||||
}
|
||||
|
||||
async function discover(key: string, client: SharedTagsClient, accountId: string): Promise<void> {
|
||||
setEntry(key, { discoveredAt: Date.now() });
|
||||
try {
|
||||
const scan = await client.discoverKeywords({ accountId, limit: DISCOVERY_SCAN_LIMIT });
|
||||
if (clients.get(key) !== client) return;
|
||||
const tags: Record<string, number> = {};
|
||||
for (const [keyword, count] of Object.entries(scan.keywords)) {
|
||||
if (tagIdFromKeyword(keyword)) tags[keyword] = count;
|
||||
}
|
||||
setEntry(key, { discovered: tags });
|
||||
} catch {
|
||||
// The tags on the messages still show on the messages themselves.
|
||||
}
|
||||
}
|
||||
|
||||
async function read(key: string, client: SharedTagsClient, accountId: string): Promise<void> {
|
||||
if (!groupConfirmed.has(key)) {
|
||||
if (typeof client.getPrincipalTypes !== 'function') {
|
||||
// No way to confirm a group: nothing shared is written for it.
|
||||
setEntry(key, { status: 'personal', loadedAt: Date.now() });
|
||||
return;
|
||||
}
|
||||
const types = await client.getPrincipalTypes([accountId]).catch(() => null);
|
||||
if (clients.get(key) !== client) return;
|
||||
if (!types) {
|
||||
// Never guess: stay on the user's own tags and ask again later.
|
||||
scheduleGroupRetry(key);
|
||||
return;
|
||||
}
|
||||
const retry = retryTimers.get(key);
|
||||
if (retry) clearTimeout(retry.timer);
|
||||
retryTimers.delete(key);
|
||||
if (types[accountId] !== 'group') {
|
||||
setEntry(key, { status: 'personal', loadedAt: Date.now() });
|
||||
return;
|
||||
}
|
||||
groupConfirmed.add(key);
|
||||
}
|
||||
|
||||
const advertised = typeof client.advertisesBulwarkMetadata === 'function'
|
||||
? client.advertisesBulwarkMetadata(accountId)
|
||||
: mayAskMetadata(client, accountId);
|
||||
if (!advertised || typeof client.getBulwarkMetadata !== 'function') {
|
||||
// 0.16: no request at all.
|
||||
setEntry(key, { status: 'unsupported', loadedAt: Date.now() });
|
||||
return;
|
||||
}
|
||||
|
||||
// getBulwarkMetadata sends its request on its own and answers null on any
|
||||
// failure, a dropped connection included; it then pauses the account.
|
||||
const metadata = mayAskMetadata(client, accountId) ? await client.getBulwarkMetadata(accountId) : null;
|
||||
if (clients.get(key) !== client) return;
|
||||
if (!metadata) {
|
||||
// A failed read keeps the last good tags.
|
||||
setEntry(key, { status: entryOf(key)?.status === 'ready' ? 'ready' : 'unavailable', readFailedAt: Date.now() });
|
||||
return;
|
||||
}
|
||||
setEntry(key, { status: 'ready', stored: readSharedTags(metadata[SHARED_TAGS_KEY]), loadedAt: Date.now(), readFailedAt: null });
|
||||
|
||||
if ((entryOf(key)?.pending ?? []).length > 0) {
|
||||
await writeChanges(key, accountId, client, []).catch(() => {});
|
||||
}
|
||||
const discoveredAt = entryOf(key)?.discoveredAt ?? 0;
|
||||
if (!discoveredAt || Date.now() - discoveredAt >= DISCOVERY_INTERVAL_MS) await discover(key, client, accountId);
|
||||
}
|
||||
|
||||
function startWatching(): void {
|
||||
if (!unsubscribePush) {
|
||||
unsubscribePush = subscribePushStateChanges((change, client) => {
|
||||
for (const [key, known] of clients) {
|
||||
if (known !== (client as unknown as SharedTagsClient)) continue;
|
||||
const accountId = accountIds.get(key);
|
||||
const state = accountId ? change.changed[accountId]?.Mailbox : undefined;
|
||||
if (!accountId || !state) continue;
|
||||
// The push of our own save.
|
||||
if (ownStates.get(key)?.delete(state)) continue;
|
||||
const status = entryOf(key)?.status;
|
||||
if (status !== 'ready' && status !== 'unavailable') continue;
|
||||
const waiting = pushTimers.get(key);
|
||||
if (waiting) clearTimeout(waiting);
|
||||
pushTimers.set(key, setTimeout(() => {
|
||||
pushTimers.delete(key);
|
||||
if (mayAskMetadata(known, accountId)) void loadSharedTags(key, accountId, known, { force: true });
|
||||
}, PUSH_DEBOUNCE_MS));
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!visibilityHandler && typeof document !== 'undefined') {
|
||||
visibilityHandler = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of Object.entries(useSharedTagsStore.getState().entries)) {
|
||||
const client = clients.get(key);
|
||||
const accountId = accountIds.get(key);
|
||||
if (!client || !accountId) continue;
|
||||
const failed = entry.status === 'unavailable' || entry.readFailedAt !== null || entry.pending.length > 0;
|
||||
const due = entry.status === 'loading'
|
||||
|| (failed && mayAskMetadata(client, accountId))
|
||||
|| (entry.status === 'ready' && now - entry.loadedAt >= STALE_AFTER_MS);
|
||||
if (due) void loadSharedTags(key, accountId, client, { force: true });
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', visibilityHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an account's shared tags once per client; `force` reads the
|
||||
* definitions again. The scan for unnamed tags runs with the first load of
|
||||
* a sign-in and then at most every 30 minutes.
|
||||
*/
|
||||
export function loadSharedTags(
|
||||
key: string,
|
||||
accountId: string,
|
||||
client: SharedTagsClient,
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const running = inflight.get(key);
|
||||
if (running && clients.get(key) === client) return running;
|
||||
const sameClient = clients.get(key) === client;
|
||||
if (sameClient && !options.force && entryOf(key)) return Promise.resolve();
|
||||
if (!sameClient) {
|
||||
// Another sign-in of this login: nothing of the old one carries over.
|
||||
groupConfirmed.delete(key);
|
||||
ownStates.delete(key);
|
||||
useSharedTagsStore.setState((state) => {
|
||||
const entries = { ...state.entries };
|
||||
delete entries[key];
|
||||
return { entries };
|
||||
});
|
||||
}
|
||||
clients.set(key, client);
|
||||
accountIds.set(key, accountId);
|
||||
startWatching();
|
||||
if (!entryOf(key)) setEntry(key, {});
|
||||
const run = read(key, client, accountId)
|
||||
.catch(() => {
|
||||
if (clients.get(key) === client) setEntry(key, { readFailedAt: Date.now() });
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflight.get(key) === run) inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Sends the pending changes plus `changes`; the stored tags follow. */
|
||||
async function writeChanges(
|
||||
key: string,
|
||||
accountId: string,
|
||||
client: SharedTagsClient,
|
||||
changes: SharedTagChange[],
|
||||
): Promise<void> {
|
||||
const entry = entryOf(key);
|
||||
const all = [...(entry?.pending ?? []), ...changes];
|
||||
let stored = entry?.stored ?? EMPTY_SHARED_TAGS;
|
||||
// Throws MetadataTooLargeError before anything is sent; nothing is kept then.
|
||||
planSharedTagsSave(stored, all);
|
||||
try {
|
||||
if (typeof client.patchBulwarkMetadata !== 'function' || typeof client.getBulwarkMetadata !== 'function') {
|
||||
throw new MetadataUnsupportedError();
|
||||
}
|
||||
// With nothing stored, patch first anyway: another member may have just
|
||||
// created the namespace, and writing it whole would drop their tag.
|
||||
const nothingStored = !stored.patchable && Object.keys(stored.entries).length === 0 && Object.keys(stored.extra).length === 0;
|
||||
if (nothingStored) stored = { ...stored, patchable: true };
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const plan = planSharedTagsSave(stored, all);
|
||||
try {
|
||||
const { newState } = await client.patchBulwarkMetadata(accountId, SHARED_TAGS_KEY, plan.patches);
|
||||
if (newState) {
|
||||
const own = ownStates.get(key) ?? new Set<string>();
|
||||
own.add(newState);
|
||||
ownStates.set(key, own);
|
||||
}
|
||||
setEntry(key, { status: 'ready', stored: plan.result, pending: [] });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!(error instanceof MetadataPatchError) || attempt > 0) throw error;
|
||||
// Not (or no longer) the map shape: read it, convert it, write it whole.
|
||||
const metadata = await client.getBulwarkMetadata(accountId);
|
||||
if (!metadata) throw new MetadataUnsupportedError();
|
||||
stored = readSharedTags(metadata[SHARED_TAGS_KEY]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof MetadataTooLargeError) throw error;
|
||||
// Kept, shown, and sent again with the next good read.
|
||||
setEntry(key, { pending: all });
|
||||
throw new SharedTagsSaveDeferredError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the shared definitions of an account, one patch per changed tag,
|
||||
* so the other tags (and what other members change meanwhile) stay as they
|
||||
* are. Throws MetadataTooLargeError when the list no longer fits (nothing
|
||||
* kept), SharedTagsSaveDeferredError when the server didn't take it (the
|
||||
* change is kept, shown and sent again with the next good read).
|
||||
*/
|
||||
export async function saveSharedTags(
|
||||
key: string,
|
||||
accountId: string,
|
||||
client: SharedTagsClient,
|
||||
changes: SharedTagChange[],
|
||||
): Promise<void> {
|
||||
clients.set(key, client);
|
||||
accountIds.set(key, accountId);
|
||||
startWatching();
|
||||
await writeChanges(key, accountId, client, changes);
|
||||
}
|
||||
|
||||
/** For tests: forget every account. */
|
||||
export function resetSharedTags(): void {
|
||||
clients.clear();
|
||||
inflight.clear();
|
||||
accountIds.clear();
|
||||
groupConfirmed.clear();
|
||||
ownStates.clear();
|
||||
for (const timer of pushTimers.values()) clearTimeout(timer);
|
||||
pushTimers.clear();
|
||||
for (const { timer } of retryTimers.values()) clearTimeout(timer);
|
||||
retryTimers.clear();
|
||||
unsubscribePush?.();
|
||||
unsubscribePush = null;
|
||||
if (visibilityHandler && typeof document !== 'undefined') document.removeEventListener('visibilitychange', visibilityHandler);
|
||||
visibilityHandler = null;
|
||||
useSharedTagsStore.setState({ entries: {} });
|
||||
}
|
||||
Reference in New Issue
Block a user