mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: act on the exact row picked in "All folders" and tag lists #1082
An "All folders" search or a tag view holds rows of the own and the shared accounts, and Stalwart hands out ids per account, so an own and a team message can share an id. The list still named its rows by bare id: selecting one of such a pair selected both, a batch action hit both, and a swipe or single-row action took whichever came first, in the other account. The list now names every row by `rowKeyOf` (its id, scoped by its account in a list spanning accounts; the bare id in a folder, so nothing changes there) end to end: - FlatList keys, row presses, swipes, selection and select-all - thread expansion of a selection (`expandThreadSelection`) - every store action the list calls: read, unread, star, pin, the per-account keyword batch, move, archive, delete and spam, and the rows they drop or keep in view - the viewer's pages, which now come only from the rows of the opened message's account (`listRowsOfAccount`), and its mark-read, which goes through the store when the list holds that account's row A key whose row is no longer loaded is ignored rather than guessed onto the open folder's account. The unified inbox already keys its rows by account and needed no change.
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
getThreadTagIds,
|
||||
tagIdFromKeyword,
|
||||
threadKeyOf,
|
||||
rowKeyOf,
|
||||
} from '../thread-utils';
|
||||
import type { Email } from '../../api/types';
|
||||
|
||||
@@ -90,4 +91,16 @@ describe('expandThreadSelection', () => {
|
||||
expect(expandThreadSelection(['b'], emails, true)).toEqual(['b']);
|
||||
expect(expandThreadSelection(['zzz'], emails, false)).toEqual(['zzz']);
|
||||
});
|
||||
|
||||
it('works on row keys, so two accounts\' same ids stay apart (#1082)', () => {
|
||||
const own = [email('a', 't1', '2026-01-01T00:00:00Z'), email('b', 't1', '2026-01-03T00:00:00Z')]
|
||||
.map((e) => ({ ...e, jmapAccountId: 'c' }));
|
||||
const team = [email('a', 't1', '2026-01-02T00:00:00Z')].map((e) => ({ ...e, jmapAccountId: 'team' }));
|
||||
const rows = [...own, ...team];
|
||||
|
||||
expect(rowKeyOf(team[0])).toBe('team:a');
|
||||
expect(rowKeyOf(emails[0])).toBe('a');
|
||||
expect(expandThreadSelection(['c:b'], rows, false)).toEqual(['c:a', 'c:b']);
|
||||
expect(expandThreadSelection(['team:a'], rows, false)).toEqual(['team:a']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,14 +11,14 @@ const LIST = [row('n1'), row('q2', 'tq'), row('q1', 'tq'), row('o3')];
|
||||
describe('viewerPages', () => {
|
||||
it('collapses the open folder to one page per thread, the opened message standing in', () => {
|
||||
const pages = viewerPages({
|
||||
emailId: 'q1', threadId: 'tq', list: LIST, listIsMessageAccount: true, threading: true,
|
||||
emailId: 'q1', threadId: 'tq', list: LIST, threading: true,
|
||||
});
|
||||
expect(pages.map((e) => e.id)).toEqual(['n1', 'q1', 'o3']);
|
||||
});
|
||||
|
||||
it('pages over every row when threading is off', () => {
|
||||
const pages = viewerPages({
|
||||
emailId: 'q1', threadId: 'tq', list: LIST, listIsMessageAccount: true, threading: false,
|
||||
emailId: 'q1', threadId: 'tq', list: LIST, threading: false,
|
||||
});
|
||||
expect(pages.map((e) => e.id)).toEqual(['n1', 'q2', 'q1', 'o3']);
|
||||
});
|
||||
@@ -26,7 +26,7 @@ describe('viewerPages', () => {
|
||||
it('does not follow the list once taken, so new mail cannot shift the page (B5)', () => {
|
||||
const list = [...LIST];
|
||||
const pages = viewerPages({
|
||||
emailId: 'q2', threadId: 'tq', list, listIsMessageAccount: true, threading: false,
|
||||
emailId: 'q2', threadId: 'tq', list, threading: false,
|
||||
});
|
||||
list.unshift(row('new1'), row('new2'));
|
||||
expect(pages.map((e) => e.id)).toEqual(['n1', 'q2', 'q1', 'o3']);
|
||||
@@ -36,27 +36,28 @@ describe('viewerPages', () => {
|
||||
it('pages over the ids another list handed over, using the store row when there is one', () => {
|
||||
const pages = viewerPages({
|
||||
emailId: 'u1', threadId: 't-u1', emailIds: ['u1', 'o3'],
|
||||
list: LIST, listIsMessageAccount: true, threading: true,
|
||||
list: LIST, threading: true,
|
||||
});
|
||||
expect(pages).toEqual([{ id: 'u1', threadId: 't-u1' }, LIST[3]]);
|
||||
});
|
||||
|
||||
it('is a single page for a message the list does not hold', () => {
|
||||
const pages = viewerPages({
|
||||
emailId: 'm9', threadId: 't9', list: LIST, listIsMessageAccount: true, threading: true,
|
||||
emailId: 'm9', threadId: 't9', list: LIST, threading: true,
|
||||
});
|
||||
expect(pages).toEqual([{ id: 'm9', threadId: 't9' }]);
|
||||
});
|
||||
|
||||
it('never takes another account\'s rows, even under the same id (B3)', () => {
|
||||
it('pages over nothing of a list of another account (B3)', () => {
|
||||
// The caller hands over only the rows of the message's account.
|
||||
const opened = viewerPages({
|
||||
emailId: 'q1', threadId: 'tq', list: LIST, listIsMessageAccount: false, threading: true,
|
||||
emailId: 'q1', threadId: 'tq', list: [], threading: true,
|
||||
});
|
||||
expect(opened).toEqual([{ id: 'q1', threadId: 'tq' }]);
|
||||
|
||||
const handed = viewerPages({
|
||||
emailId: 'o3', threadId: 't-o3', emailIds: ['o3'],
|
||||
list: LIST, listIsMessageAccount: false, threading: true,
|
||||
list: [], threading: true,
|
||||
});
|
||||
expect(handed).toEqual([{ id: 'o3', threadId: 't-o3' }]);
|
||||
});
|
||||
|
||||
+26
-15
@@ -57,6 +57,15 @@ export function accountScopedId(email: Pick<Email, 'jmapAccountId'>, id: string)
|
||||
return email.jmapAccountId ? `${email.jmapAccountId}:${id}` : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A list row's identity: its id, scoped by its account in a list spanning
|
||||
* accounts, where an own and a shared message can share an id. What the list
|
||||
* keys, selects and hands to the store's actions (#1082).
|
||||
*/
|
||||
export function rowKeyOf(email: Pick<Email, 'id' | 'jmapAccountId'>): string {
|
||||
return accountScopedId(email, email.id);
|
||||
}
|
||||
|
||||
/** Key a message groups under: its thread, or itself when threading is off. */
|
||||
export function threadKeyOf(email: Email, disableThreading: boolean): string {
|
||||
return accountScopedId(email, disableThreading ? email.id : email.threadId || email.id);
|
||||
@@ -121,33 +130,35 @@ export function groupByThread(emails: Email[], disableThreading: boolean): Map<s
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands selected representative ids to every loaded message of their
|
||||
* threads, so batch actions on "3 selected" conversations touch all their
|
||||
* messages rather than one each. Ids that are not in `emails` pass through.
|
||||
* Expands selected representative rows (by `rowKeyOf`) to every loaded
|
||||
* message of their threads, so batch actions on "3 selected" conversations
|
||||
* touch all their messages rather than one each. Keys that are not in
|
||||
* `emails` pass through.
|
||||
*/
|
||||
export function expandThreadSelection(
|
||||
ids: Iterable<string>,
|
||||
keys: Iterable<string>,
|
||||
emails: Email[],
|
||||
disableThreading: boolean,
|
||||
): string[] {
|
||||
const wanted = new Set(ids);
|
||||
const wanted = new Set(keys);
|
||||
if (disableThreading) return [...wanted];
|
||||
const byId = new Map(emails.map((e) => [e.id, e]));
|
||||
const keys = new Set<string>();
|
||||
for (const id of wanted) {
|
||||
const e = byId.get(id);
|
||||
if (e) keys.add(threadKeyOf(e, false));
|
||||
const byKey = new Map(emails.map((e) => [rowKeyOf(e), e]));
|
||||
const threads = new Set<string>();
|
||||
for (const key of wanted) {
|
||||
const e = byKey.get(key);
|
||||
if (e) threads.add(threadKeyOf(e, false));
|
||||
}
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of emails) {
|
||||
if (keys.has(threadKeyOf(e, false)) && !seen.has(e.id)) {
|
||||
seen.add(e.id);
|
||||
out.push(e.id);
|
||||
const key = rowKeyOf(e);
|
||||
if (threads.has(threadKeyOf(e, false)) && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
out.push(key);
|
||||
}
|
||||
}
|
||||
for (const id of wanted) {
|
||||
if (!seen.has(id)) { seen.add(id); out.push(id); }
|
||||
for (const key of wanted) {
|
||||
if (!seen.has(key)) { seen.add(key); out.push(key); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ export interface ViewerPagesInput {
|
||||
threadId: string;
|
||||
/** Ids handed over by a list other than the open folder (unified inbox, contact activity). */
|
||||
emailIds?: string[];
|
||||
/** The store's loaded list: the open folder's rows. */
|
||||
/**
|
||||
* The store's loaded rows that live in the message's account: none when the
|
||||
* open folder is another account's, and only that account's rows of a list
|
||||
* spanning accounts. Ids are only unique per account (B3, #1082).
|
||||
*/
|
||||
list: Email[];
|
||||
/** Whether that list is the message's account: ids are only unique per account. */
|
||||
listIsMessageAccount: boolean;
|
||||
threading: boolean;
|
||||
}
|
||||
|
||||
@@ -26,9 +28,7 @@ export interface ViewerPagesInput {
|
||||
* toolbar acts on the tapped message.
|
||||
*/
|
||||
export function viewerPages(input: ViewerPagesInput): Email[] {
|
||||
const { emailId, threadId, emailIds, threading } = input;
|
||||
// Another account's rows say nothing about this message, whatever their ids.
|
||||
const list = input.listIsMessageAccount ? input.list : [];
|
||||
const { emailId, threadId, emailIds, threading, list } = input;
|
||||
if (emailIds && emailIds.length > 0) {
|
||||
const byId = new Map(list.map((e) => [e.id, e]));
|
||||
return emailIds.map((id) => byId.get(id) ?? ({ id, threadId } as Email));
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
} from '../lib/mailbox-tree';
|
||||
import { localizeMailboxName } from '../lib/mailbox-label';
|
||||
import {
|
||||
collapseThreads, groupByThread, expandThreadSelection, getThreadTagIds, threadKeyOf, accountScopedId,
|
||||
collapseThreads, groupByThread, expandThreadSelection, getThreadTagIds, threadKeyOf, accountScopedId, rowKeyOf,
|
||||
} from '../lib/thread-utils';
|
||||
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
|
||||
import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context';
|
||||
@@ -144,8 +144,11 @@ const EmailRow = React.memo(function EmailRow({
|
||||
});
|
||||
}, [tagIds, keywordDefs, c]);
|
||||
|
||||
const handlePress = React.useCallback(() => onPress(item.id), [onPress, item.id]);
|
||||
const handleLongPress = React.useCallback(() => onLongPress(item.id), [onLongPress, item.id]);
|
||||
// The row's key (`rowKeyOf`): ids repeat across the accounts of a list
|
||||
// spanning accounts (#1082).
|
||||
const key = rowKeyOf(item);
|
||||
const handlePress = React.useCallback(() => onPress(key), [onPress, key]);
|
||||
const handleLongPress = React.useCallback(() => onLongPress(key), [onLongPress, key]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -281,9 +284,10 @@ const EmailListItem = React.memo(function EmailListItem({
|
||||
() => ({ unread, starred, pinned, inJunk }),
|
||||
[unread, starred, pinned, inJunk],
|
||||
);
|
||||
const key = rowKeyOf(item);
|
||||
const onAction = React.useCallback(
|
||||
(action: SwipeAction) => onSwipe(item.id, action),
|
||||
[onSwipe, item.id],
|
||||
(action: SwipeAction) => onSwipe(key, action),
|
||||
[onSwipe, key],
|
||||
);
|
||||
return (
|
||||
<SwipeableRow
|
||||
@@ -305,7 +309,7 @@ function EmailRowSeparator() {
|
||||
}
|
||||
|
||||
// Rows of a list spanning accounts can share an id (#1082).
|
||||
const emailKeyExtractor = (item: Email) => accountScopedId(item, item.id);
|
||||
const emailKeyExtractor = rowKeyOf;
|
||||
|
||||
interface EmailListScreenProps {
|
||||
onEmailPress?: (email: Email) => void;
|
||||
@@ -511,7 +515,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
||||
const selectionMode = selectedIds.size > 0;
|
||||
const allSelected =
|
||||
visibleEmails.length > 0 && visibleEmails.every((e) => selectedIds.has(e.id));
|
||||
visibleEmails.length > 0 && visibleEmails.every((e) => selectedIds.has(rowKeyOf(e)));
|
||||
|
||||
// Refs so row press handlers stay referentially stable across renders.
|
||||
// FlatList rows then skip re-render when the parent re-renders for unrelated
|
||||
@@ -560,7 +564,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
if (selectionModeRef.current) {
|
||||
toggleSelect(id);
|
||||
} else {
|
||||
const email = emailsRef.current.find((e) => e.id === id);
|
||||
const email = emailsRef.current.find((e) => rowKeyOf(e) === id);
|
||||
if (!email) return;
|
||||
if (isDraftEmail(email, currentRoleRef.current)) void openDraftRef.current(email);
|
||||
else onEmailPressRef.current?.(email);
|
||||
@@ -599,7 +603,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
|
||||
const handleSwipeAction = React.useCallback((id: string, action: SwipeAction) => {
|
||||
if (action === 'none') return;
|
||||
const email = emailsRef.current.find((e) => e.id === id);
|
||||
const email = emailsRef.current.find((e) => rowKeyOf(e) === id);
|
||||
if (!email || !currentMailboxId) return;
|
||||
switch (action) {
|
||||
case 'archive':
|
||||
@@ -682,7 +686,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
disableAvatarImages={inJunk && !showAvatarsInJunk}
|
||||
answered={flags?.answered ?? false}
|
||||
forwarded={flags?.forwarded ?? false}
|
||||
selected={selectedIds.has(item.id)}
|
||||
selected={selectedIds.has(rowKeyOf(item))}
|
||||
selectionMode={selectionMode}
|
||||
onPress={handleRowPress}
|
||||
onLongPress={toggleSelect}
|
||||
@@ -707,9 +711,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
|
||||
const toggleSelectAllVisible = React.useCallback(() => {
|
||||
setSelectedIds((prev) => {
|
||||
const allCurrent = visibleEmails.length > 0 && visibleEmails.every((e) => prev.has(e.id));
|
||||
const allCurrent = visibleEmails.length > 0 && visibleEmails.every((e) => prev.has(rowKeyOf(e)));
|
||||
if (allCurrent) return new Set();
|
||||
return new Set(visibleEmails.map((e) => e.id));
|
||||
return new Set(visibleEmails.map(rowKeyOf));
|
||||
});
|
||||
}, [visibleEmails]);
|
||||
|
||||
@@ -718,7 +722,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
setSelectedIds(new Set());
|
||||
}, [currentMailboxId]);
|
||||
|
||||
// `selectedIds` holds the representative row ids; every action expands to
|
||||
// `selectedIds` holds the representative rows' keys (`rowKeyOf`, unique
|
||||
// across the accounts of a list spanning accounts); every action expands to
|
||||
// all loaded messages of the selected conversations (webmail
|
||||
// `toggleThreadSelection`), so "3 selected" conversations never means
|
||||
// "3 messages touched".
|
||||
@@ -728,7 +733,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
);
|
||||
const selectedEmails = React.useMemo(() => {
|
||||
const wanted = new Set(selectedMessageIds);
|
||||
return emails.filter((e) => wanted.has(e.id));
|
||||
return emails.filter((e) => wanted.has(rowKeyOf(e)));
|
||||
}, [emails, selectedMessageIds]);
|
||||
const allSelectedAreRead = selectedEmails.length > 0 && selectedEmails.every((e) => !isUnread(e));
|
||||
const allSelectedAreStarred = selectedEmails.length > 0 && selectedEmails.every((e) => isStarred(e));
|
||||
|
||||
@@ -18,7 +18,7 @@ import { MoveSheet } from '../components/MoveSheet';
|
||||
import { ThreadMessageCard, ThreadCardPlaceholder } from '../components/email/ThreadMessageCard';
|
||||
import { QuickReplyBox } from '../components/email/QuickReplyBox';
|
||||
import { AddressActionSheet } from '../components/email/AddressActionSheet';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { useEmailStore, listRowsOfAccount } from '../stores/email-store';
|
||||
import { toast } from '../stores/toast-store';
|
||||
import {
|
||||
useSettingsStore,
|
||||
@@ -126,8 +126,10 @@ function EmailViewer({ route, navigation }: Props) {
|
||||
emailId: route.params.emailId,
|
||||
threadId: route.params.threadId,
|
||||
emailIds: route.params.emailIds,
|
||||
list: useEmailStore.getState().emails,
|
||||
listIsMessageAccount: listAccountId === ownerAccountId,
|
||||
// Only the list rows of the message's account: none when the open
|
||||
// folder is another's, that account's rows of an "All folders" list or
|
||||
// a tag view, whose ids repeat across accounts (#1082).
|
||||
list: listRowsOfAccount(ownerAccountId),
|
||||
threading: !disableThreading,
|
||||
}).map((page) => (page.receivedAt ? page : peekRow(page.id, ownerAccountId) ?? page)));
|
||||
|
||||
@@ -310,10 +312,11 @@ function EmailViewer({ route, navigation }: Props) {
|
||||
}, [ownerAccountId]);
|
||||
|
||||
// Mark read through the store (its list row and offline queue) only when
|
||||
// the list holds the message's account; the store would otherwise read the
|
||||
// id as a row of the open folder's account (B3), so go to the server.
|
||||
// the list holds the message's account, or its row in an "All folders"
|
||||
// list or a tag view (#1082); the store would otherwise read the id as a
|
||||
// row of the open folder's account (B3), so go to the server.
|
||||
const markSeen = React.useCallback(
|
||||
(id: string) => (listAccountId === ownerAccountId
|
||||
(id: string) => (listAccountId === ownerAccountId || listRowsOfAccount(ownerAccountId).some((e) => e.id === id)
|
||||
? markRead(id, ownerAccountId)
|
||||
: patchKeywordsForEmails([id], { $seen: true }, ownerAccountId)),
|
||||
[listAccountId, ownerAccountId, markRead],
|
||||
|
||||
@@ -91,8 +91,9 @@ vi.mock('../offline-cache-store', () => ({
|
||||
import { generateAccountId } from '../../lib/account-utils';
|
||||
import * as settingsModule from '../settings-store';
|
||||
import {
|
||||
useEmailStore, viewerParamsForRow, deleteDestroysAcrossAccounts, accountIdOfRow,
|
||||
useEmailStore, viewerParamsForRow, deleteDestroysAcrossAccounts, accountIdOfRow, listRowsOfAccount,
|
||||
} from '../email-store';
|
||||
import { expandThreadSelection, rowKeyOf } from '../../lib/thread-utils';
|
||||
import { useTagCountsStore } from '../tag-counts-store';
|
||||
import type { Email, Mailbox } from '../../api/types';
|
||||
|
||||
@@ -333,7 +334,7 @@ describe('opening and acting on "All folders" rows (#1082)', () => {
|
||||
|
||||
it('marks a mixed selection read with one Email/set per account', async () => {
|
||||
await search('zephyr');
|
||||
await useEmailStore.getState().setKeywordForEmails(['o1', 'm1'], '$seen', true);
|
||||
await useEmailStore.getState().setKeywordForEmails(['c:o1', 'team:m1'], '$seen', true);
|
||||
|
||||
const sets = server.current!.callsOf('Email/set');
|
||||
expect(sets.map(([, a]) => [a.accountId, Object.keys(a.update as object)])).toEqual([['team', ['m1']], ['c', ['o1']]]);
|
||||
@@ -342,7 +343,7 @@ describe('opening and acting on "All folders" rows (#1082)', () => {
|
||||
|
||||
it('stars a team row in the team account', async () => {
|
||||
await search('zephyr');
|
||||
await useEmailStore.getState().toggleStar('m3', true);
|
||||
await useEmailStore.getState().toggleStar('team:m3', true);
|
||||
|
||||
expect(server.current!.callsOf('Email/set')[0][1]).toEqual({
|
||||
accountId: 'team', update: { m3: { 'keywords/$flagged': true } },
|
||||
@@ -351,7 +352,7 @@ describe('opening and acting on "All folders" rows (#1082)', () => {
|
||||
|
||||
it('archives each row into its own account\'s Archive, and undo puts them back', async () => {
|
||||
await search('zephyr');
|
||||
await useEmailStore.getState().archiveEmailsBatch(['o1', 'm1']);
|
||||
await useEmailStore.getState().archiveEmailsBatch(['c:o1', 'team:m1']);
|
||||
|
||||
const sets = server.current!.callsOf('Email/set');
|
||||
expect(sets.map(([, a]) => [a.accountId, a.update])).toEqual([
|
||||
@@ -371,17 +372,17 @@ describe('opening and acting on "All folders" rows (#1082)', () => {
|
||||
it('moves a hit to Trash out of the folder it is in, not the open one', async () => {
|
||||
await search('zephyr');
|
||||
// o2 sits in Archive while the Inbox is open.
|
||||
await useEmailStore.getState().deleteEmail('o2', 'trash', 'inbox');
|
||||
await useEmailStore.getState().deleteEmail('c:o2', 'trash', 'inbox');
|
||||
|
||||
expect(server.current!.accounts.c.find((e) => e.id === 'o2')!.mailboxIds).toEqual({ trash: true });
|
||||
});
|
||||
|
||||
it('confirms before deleting a hit that already sits in its account\'s Trash', async () => {
|
||||
await search('zephyr');
|
||||
expect(deleteDestroysAcrossAccounts(['m1'])).toBe(false);
|
||||
expect(deleteDestroysAcrossAccounts(['m1', 'm5'])).toBe(true);
|
||||
expect(deleteDestroysAcrossAccounts(['team:m1'])).toBe(false);
|
||||
expect(deleteDestroysAcrossAccounts(['team:m1', 'team:m5'])).toBe(true);
|
||||
|
||||
await useEmailStore.getState().deleteEmailsBatch(['m1', 'm5'], 'trash', 'inbox');
|
||||
await useEmailStore.getState().deleteEmailsBatch(['team:m1', 'team:m5'], 'trash', 'inbox');
|
||||
expect(server.current!.callsOf('Email/set').map(([, a]) => [a.accountId, a.update ?? a.destroy])).toEqual([
|
||||
['team', ['m5']],
|
||||
['team', { m1: { mailboxIds: { 't-trash': true } } }],
|
||||
@@ -391,7 +392,7 @@ describe('opening and acting on "All folders" rows (#1082)', () => {
|
||||
|
||||
it('files spam in each row\'s own Junk', async () => {
|
||||
await search('zephyr');
|
||||
await useEmailStore.getState().markSpam(['m2']);
|
||||
await useEmailStore.getState().markSpam(['team:m2']);
|
||||
|
||||
expect(server.current!.callsOf('Email/set')[0][1]).toEqual({
|
||||
accountId: 'team',
|
||||
@@ -410,6 +411,90 @@ describe('opening and acting on "All folders" rows (#1082)', () => {
|
||||
// #1038: a tag is set on messages in the own and in the team account alike,
|
||||
// but the tag view only asked the account of the open folder: 2 own and 2
|
||||
// team messages tagged Red showed 2 or the other 2, depending on the folder.
|
||||
// Stalwart hands out per-account counters, so the own and the team account
|
||||
// both hold an "x" in a thread "t-x". The list names its rows by `rowKeyOf`
|
||||
// (`c:x`, `team:x`), so selection, swipe and batch actions reach exactly the
|
||||
// row the user picked, in its own account.
|
||||
describe('same-id rows of two accounts (#1082)', () => {
|
||||
beforeEach(async () => {
|
||||
server.current = createFakeJmap({
|
||||
c: [mail('x', 'inbox', 20, 'zephyr own')],
|
||||
team: [mail('x', 't-inbox', 21, 'zephyr team')],
|
||||
});
|
||||
await search('zephyr');
|
||||
});
|
||||
|
||||
const rows = () => useEmailStore.getState().emails.map((e) => [e.jmapAccountId, e.id, !!e.keywords.$seen]);
|
||||
|
||||
it('keeps both rows apart in selection and conversation', () => {
|
||||
const emails = useEmailStore.getState().emails;
|
||||
expect(emails.map(rowKeyOf)).toEqual(['team:x', 'c:x']);
|
||||
// Selecting the team row selects it alone, even with threading on.
|
||||
expect(expandThreadSelection(['team:x'], emails, false)).toEqual(['team:x']);
|
||||
});
|
||||
|
||||
it('select all + mark read reads both, each in its own account', async () => {
|
||||
const emails = useEmailStore.getState().emails;
|
||||
const selected = expandThreadSelection(emails.map(rowKeyOf), emails, false);
|
||||
await useEmailStore.getState().setKeywordForEmails(selected, '$seen', true);
|
||||
|
||||
expect(server.current!.callsOf('Email/set').map(([, a]) => [a.accountId, a.update])).toEqual([
|
||||
['team', { x: { 'keywords/$seen': true } }],
|
||||
['c', { x: { 'keywords/$seen': true } }],
|
||||
]);
|
||||
expect(rows()).toEqual([['team', 'x', true], ['c', 'x', true]]);
|
||||
});
|
||||
|
||||
it('marks only the selected row read', async () => {
|
||||
await useEmailStore.getState().setKeywordForEmails(['c:x'], '$seen', true);
|
||||
|
||||
expect(server.current!.callsOf('Email/set').map(([, a]) => a.accountId)).toEqual(['c']);
|
||||
expect(rows()).toEqual([['team', 'x', false], ['c', 'x', true]]);
|
||||
});
|
||||
|
||||
it('swipe-archives the team row alone', async () => {
|
||||
await useEmailStore.getState().archiveEmail('team:x');
|
||||
|
||||
expect(server.current!.callsOf('Email/set').map(([, a]) => [a.accountId, a.update])).toEqual([
|
||||
['team', { x: { mailboxIds: { 't-archive': true } } }],
|
||||
]);
|
||||
expect(server.current!.accounts.c[0].mailboxIds).toEqual({ inbox: true });
|
||||
expect(rows()).toEqual([['c', 'x', false]]);
|
||||
});
|
||||
|
||||
it('swipe-reads the own row alone', async () => {
|
||||
await useEmailStore.getState().markRead('c:x');
|
||||
|
||||
expect(server.current!.callsOf('Email/set').map(([, a]) => a.accountId)).toEqual(['c']);
|
||||
expect(rows()).toEqual([['team', 'x', false], ['c', 'x', true]]);
|
||||
});
|
||||
|
||||
it('batch-deletes the own row alone', async () => {
|
||||
expect(deleteDestroysAcrossAccounts(['c:x'])).toBe(false);
|
||||
await useEmailStore.getState().deleteEmailsBatch(['c:x'], 'trash', 'inbox');
|
||||
|
||||
expect(server.current!.callsOf('Email/set').map(([, a]) => [a.accountId, a.update])).toEqual([
|
||||
['c', { x: { mailboxIds: { trash: true } } }],
|
||||
]);
|
||||
expect(server.current!.accounts.team[0].mailboxIds).toEqual({ 't-inbox': true });
|
||||
expect(rows()).toEqual([['team', 'x', false]]);
|
||||
});
|
||||
|
||||
it('does nothing for a row that is no longer loaded, rather than guess its account', async () => {
|
||||
useEmailStore.setState({ emails: [] });
|
||||
await useEmailStore.getState().markRead('team:x');
|
||||
await useEmailStore.getState().moveToMailbox('team:x', 'inbox', 'archive');
|
||||
await useEmailStore.getState().deleteEmail('team:x', 'trash', 'inbox');
|
||||
|
||||
expect(server.current!.callsOf('Email/set')).toEqual([]);
|
||||
});
|
||||
|
||||
it('hands the viewer only the rows of the opened message\'s account', () => {
|
||||
expect(listRowsOfAccount('team').map(rowKeyOf)).toEqual(['team:x']);
|
||||
expect(listRowsOfAccount(undefined).map(rowKeyOf)).toEqual(['c:x']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag view across the own and the team account (#1038)', () => {
|
||||
const RED = '$label:red';
|
||||
|
||||
@@ -467,13 +552,13 @@ describe('tag view across the own and the team account (#1038)', () => {
|
||||
|
||||
it('untags a team row in the team account and keeps it in view', async () => {
|
||||
await openTag('inbox');
|
||||
await useEmailStore.getState().setKeywordForEmails(['m2'], RED, false);
|
||||
await useEmailStore.getState().setKeywordForEmails(['team:m2'], RED, false);
|
||||
|
||||
expect(server.current!.callsOf('Email/set')[0][1]).toEqual({
|
||||
accountId: 'team', update: { m2: { 'keywords/$label:red': null } },
|
||||
});
|
||||
expect(ids()).toContain('m2');
|
||||
expect(useEmailStore.getState().retainedIds).toEqual(['m2']);
|
||||
expect(useEmailStore.getState().retainedIds).toEqual(['team:m2']);
|
||||
});
|
||||
|
||||
it('pages each account on from its own rows', async () => {
|
||||
|
||||
+108
-71
@@ -42,7 +42,7 @@ import {
|
||||
mailboxesForSiblingOf, mailboxesOfAccount, findJunkMailbox, findArchiveMailbox, findTrashMailbox, ownMailboxes,
|
||||
} from '../lib/mailbox-tree';
|
||||
import { toWildcardQuery } from '../lib/search-utils';
|
||||
import { collapseThreads, accountScopedId } from '../lib/thread-utils';
|
||||
import { collapseThreads, rowKeyOf } from '../lib/thread-utils';
|
||||
import { compareEmails, levelKeyword, orderForMailbox, sanitizeSortLevels, type SortLevel } from '../lib/message-list-order';
|
||||
import { buildListSort, markKeywordSortUnsupported } from '../lib/keyword-sort-polarity';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
@@ -162,7 +162,10 @@ function actionTarget(
|
||||
// A row is the viewer's message only in the message's account: a folder's
|
||||
// rows are in the folder's, a row of a list spanning accounts in its own
|
||||
// (#1082).
|
||||
const row = state.emails.find((e) => e.id === emailId && (!viewed || rowAccountId(state, e) === viewed.accountId));
|
||||
// The list names its row by `rowKeyOf`, unique across accounts.
|
||||
const row = viewed
|
||||
? state.emails.find((e) => e.id === emailId && rowAccountId(state, e) === viewed.accountId)
|
||||
: state.emails.find((e) => rowKeyOf(e) === emailId);
|
||||
return { email: row ?? viewed?.email, listed: !viewed || !!row };
|
||||
}
|
||||
|
||||
@@ -172,7 +175,7 @@ function actionTargets(
|
||||
emailIds: string[],
|
||||
viewed?: ViewedEmail,
|
||||
): { targets: Email[]; listed: boolean } {
|
||||
if (!viewed) return { targets: state.emails.filter((e) => emailIds.includes(e.id)), listed: true };
|
||||
if (!viewed) return { targets: rowsByKey(state, emailIds), listed: true };
|
||||
const { email, listed } = actionTarget(state, viewed.email.id, viewed);
|
||||
return { targets: email ? [email] : [], listed };
|
||||
}
|
||||
@@ -351,10 +354,10 @@ export interface EmailState {
|
||||
filters: EmailFilters;
|
||||
pendingUndo: UndoEntry | null;
|
||||
/**
|
||||
* Rows the user just read/unstarred/untagged while an Unread/Starred/tag
|
||||
* view was open. They stay in the list until the view is re-opened even
|
||||
* though the server query no longer matches them (webmail 1.9.0
|
||||
* `retainedInViewIds`).
|
||||
* Rows (by `rowKeyOf`) the user just read/unstarred/untagged while an
|
||||
* Unread/Starred/tag view was open. They stay in the list until the view
|
||||
* is re-opened even though the server query no longer matches them
|
||||
* (webmail 1.9.0 `retainedInViewIds`).
|
||||
*/
|
||||
retainedIds: string[];
|
||||
/**
|
||||
@@ -585,9 +588,27 @@ function groupByAccount(state: EmailState, emails: Email[]): Array<{ accountId?:
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
// The rows among `ids` that come from a list spanning accounts.
|
||||
function spanningRows(state: EmailState, ids: string[]): Email[] {
|
||||
return state.emails.filter((e) => !!e.jmapAccountId && ids.includes(e.id));
|
||||
// The loaded rows the list names by `rowKeyOf`: unique across the accounts
|
||||
// of a list spanning accounts, and the bare id for a folder's rows.
|
||||
function rowsByKey(state: EmailState, keys: string[]): Email[] {
|
||||
const wanted = new Set(keys);
|
||||
return state.emails.filter((e) => wanted.has(rowKeyOf(e)));
|
||||
}
|
||||
|
||||
// The JMAP id inside a row key (ids never contain ':', RFC 8620 §1.2).
|
||||
function idOfRowKey(key: string): string {
|
||||
return key.slice(key.lastIndexOf(':') + 1);
|
||||
}
|
||||
|
||||
// A key naming a row of a list spanning accounts that is no longer loaded:
|
||||
// its account can't be told any more, so there is nothing to act on.
|
||||
function isGoneSpanningRow(key: string, row: Email | undefined): boolean {
|
||||
return !row && idOfRowKey(key) !== key;
|
||||
}
|
||||
|
||||
// The rows among `keys` that come from a list spanning accounts.
|
||||
function spanningRows(state: EmailState, keys: string[]): Email[] {
|
||||
return rowsByKey(state, keys).filter((e) => !!e.jmapAccountId);
|
||||
}
|
||||
|
||||
// Client-side mirror of the list sort, for merging the accounts' pages.
|
||||
@@ -688,6 +709,16 @@ export function accountIdOfRow(email: Email): string | undefined {
|
||||
return rowAccountId(useEmailStore.getState(), email);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loaded rows that live in one JMAP account (undefined = the user's own):
|
||||
* the open folder's rows when it is that account's, that account's rows of a
|
||||
* list spanning accounts. Ids are unique among them.
|
||||
*/
|
||||
export function listRowsOfAccount(accountId: string | undefined): Email[] {
|
||||
const state = useEmailStore.getState();
|
||||
return state.emails.filter((e) => rowAccountId(state, e) === accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewer route params for a row of a list that spans accounts: its own
|
||||
* account (undefined = the user's), whatever folder is open, and the rows of
|
||||
@@ -707,11 +738,11 @@ export function viewerParamsForRow(email: Email): { jmapAccountId?: string; emai
|
||||
// Unread/Starred view at their previous position (webmail `mergeRetainedRows`).
|
||||
function mergeRetainedRows(previous: Email[], fresh: Email[], retainedIds: string[]): Email[] {
|
||||
if (retainedIds.length === 0) return fresh;
|
||||
const freshIds = new Set(fresh.map((e) => e.id));
|
||||
const freshKeys = new Set(fresh.map(rowKeyOf));
|
||||
const retained = new Set(retainedIds);
|
||||
const out = [...fresh];
|
||||
previous.forEach((e, index) => {
|
||||
if (!retained.has(e.id) || freshIds.has(e.id)) return;
|
||||
if (!retained.has(rowKeyOf(e)) || freshKeys.has(rowKeyOf(e))) return;
|
||||
out.splice(Math.min(index, out.length), 0, e);
|
||||
});
|
||||
return out;
|
||||
@@ -1036,7 +1067,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
// Unread view, unstarred in Starred, untagged in a tag view) are no longer
|
||||
// part of the server's result; counting them would skip as many messages.
|
||||
const retained = new Set(state.retainedIds);
|
||||
const position = emails.filter((e) => !retained.has(e.id)).length;
|
||||
const position = emails.filter((e) => !retained.has(rowKeyOf(e))).length;
|
||||
if (!currentMailboxId || loading || position >= totalEmails) return;
|
||||
if (!jmapClientServesActiveAccount(activeAccountId)) return;
|
||||
|
||||
@@ -1049,7 +1080,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
// Each account continues from its own rows on screen (#1082).
|
||||
const loaded: Record<string, number> = {};
|
||||
for (const e of emails) {
|
||||
if (e.jmapAccountId && !retained.has(e.id)) loaded[e.jmapAccountId] = (loaded[e.jmapAccountId] ?? 0) + 1;
|
||||
if (e.jmapAccountId && !retained.has(rowKeyOf(e))) loaded[e.jmapAccountId] = (loaded[e.jmapAccountId] ?? 0) + 1;
|
||||
}
|
||||
const page = await fetchSpanningPage(state, loaded, filter, limit);
|
||||
const now = get();
|
||||
@@ -1057,9 +1088,9 @@ export const useEmailStore = create<EmailState>()(
|
||||
now.activeAccountId !== activeAccountId || now.currentMailboxId !== currentMailboxId ||
|
||||
now.searchQuery !== searchQuery || now.filters !== filters
|
||||
) return;
|
||||
const shown = new Set(now.emails.map((e) => accountScopedId(e, e.id)));
|
||||
const shown = new Set(now.emails.map(rowKeyOf));
|
||||
set({
|
||||
emails: [...now.emails, ...page.list.filter((e) => !shown.has(accountScopedId(e, e.id)))]
|
||||
emails: [...now.emails, ...page.list.filter((e) => !shown.has(rowKeyOf(e)))]
|
||||
.sort(listComparator(now)),
|
||||
// An account that failed this time keeps counting what it showed,
|
||||
// so load-more doesn't keep asking for it.
|
||||
@@ -1263,10 +1294,13 @@ export const useEmailStore = create<EmailState>()(
|
||||
|
||||
markRead: async (emailId, accountId) => {
|
||||
const state = get();
|
||||
// Ids repeat across the accounts of a list spanning accounts: take the
|
||||
// row of the account asked for when there is one (#1082).
|
||||
const email = state.emails.find((e) => e.id === emailId && rowAccountId(state, e) === accountId)
|
||||
?? state.emails.find((e) => e.id === emailId);
|
||||
// The list names its row by `rowKeyOf`; the viewer by id and account, as
|
||||
// ids repeat across the accounts of a list spanning accounts (#1082).
|
||||
const email = state.emails.find((e) => rowKeyOf(e) === emailId)
|
||||
?? state.emails.find((e) => e.id === emailId && rowAccountId(state, e) === accountId);
|
||||
if (isGoneSpanningRow(emailId, email)) return;
|
||||
const id = email?.id ?? emailId;
|
||||
const key = email ? rowKeyOf(email) : emailId;
|
||||
// Only `$seen` goes to the server: the message may not be in the list at
|
||||
// all, and a whole keyword map would erase its stars and tags.
|
||||
const patch = { $seen: true };
|
||||
@@ -1274,81 +1308,81 @@ export const useEmailStore = create<EmailState>()(
|
||||
// JMAP account and isn't in the active list/cache or the (account-scoped)
|
||||
// offline queue — mark it read directly against its owning account.
|
||||
if (accountId && !email) {
|
||||
await patchKeywordsForEmails([emailId], patch, accountId);
|
||||
await patchKeywordsForEmails([id], patch, accountId);
|
||||
return;
|
||||
}
|
||||
const owner = accountId ?? rowAccountId(state, email);
|
||||
await applyOrQueue({ kind: 'keywords', emailId, accountId: owner, patch });
|
||||
await applyOrQueue({ kind: 'keywords', emailId: id, accountId: owner, patch });
|
||||
set({
|
||||
emails: get().emails.map((e) =>
|
||||
e.id === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
|
||||
rowKeyOf(e) === key ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
|
||||
),
|
||||
...(state.filters.isUnread === true ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}),
|
||||
...(state.filters.isUnread === true ? { retainedIds: retain(get().retainedIds, [key]) } : {}),
|
||||
});
|
||||
patchCache(emailId, { keywords: patch }, owner);
|
||||
patchCache(id, { keywords: patch }, owner);
|
||||
},
|
||||
|
||||
markUnread: async (emailId) => {
|
||||
const state = get();
|
||||
const email = state.emails.find((e) => e.id === emailId);
|
||||
const email = state.emails.find((e) => rowKeyOf(e) === emailId);
|
||||
if (!email) return;
|
||||
const patch = { $seen: null };
|
||||
await applyOrQueue({
|
||||
kind: 'keywords',
|
||||
emailId,
|
||||
emailId: email.id,
|
||||
accountId: rowAccountId(state, email),
|
||||
patch,
|
||||
});
|
||||
set({
|
||||
emails: get().emails.map((e) =>
|
||||
e.id === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
|
||||
rowKeyOf(e) === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
|
||||
),
|
||||
...(state.filters.isUnread === false ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}),
|
||||
});
|
||||
patchCache(emailId, { keywords: patch }, rowAccountId(state, email));
|
||||
patchCache(email.id, { keywords: patch }, rowAccountId(state, email));
|
||||
},
|
||||
|
||||
toggleStar: async (emailId, starred) => {
|
||||
const state = get();
|
||||
const email = state.emails.find((e) => e.id === emailId);
|
||||
const email = state.emails.find((e) => rowKeyOf(e) === emailId);
|
||||
if (!email) return;
|
||||
const patch = { $flagged: starred ? true : null };
|
||||
await applyOrQueue({
|
||||
kind: 'keywords',
|
||||
emailId,
|
||||
emailId: email.id,
|
||||
accountId: rowAccountId(state, email),
|
||||
patch,
|
||||
});
|
||||
set({
|
||||
emails: get().emails.map((e) =>
|
||||
e.id === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
|
||||
rowKeyOf(e) === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
|
||||
),
|
||||
...(state.filters.isStarred !== undefined && state.filters.isStarred !== starred
|
||||
? { retainedIds: retain(get().retainedIds, [emailId]) }
|
||||
: {}),
|
||||
});
|
||||
patchCache(emailId, { keywords: patch }, rowAccountId(state, email));
|
||||
patchCache(email.id, { keywords: patch }, rowAccountId(state, email));
|
||||
},
|
||||
|
||||
togglePin: async (emailId, pinned) => {
|
||||
const state = get();
|
||||
const email = state.emails.find((e) => e.id === emailId);
|
||||
const email = state.emails.find((e) => rowKeyOf(e) === emailId);
|
||||
if (!email) return;
|
||||
// `$pinned` is what the webmail reads and writes; a pin set as
|
||||
// `$important` was invisible to it (and vice versa).
|
||||
const patch = { $pinned: pinned ? true : null };
|
||||
await applyOrQueue({
|
||||
kind: 'keywords',
|
||||
emailId,
|
||||
emailId: email.id,
|
||||
accountId: rowAccountId(state, email),
|
||||
patch,
|
||||
});
|
||||
set({
|
||||
emails: get().emails.map((e) =>
|
||||
e.id === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
|
||||
rowKeyOf(e) === emailId ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
|
||||
),
|
||||
});
|
||||
patchCache(emailId, { keywords: patch }, rowAccountId(state, email));
|
||||
patchCache(email.id, { keywords: patch }, rowAccountId(state, email));
|
||||
},
|
||||
|
||||
markSpam: async (emailIds, viewed) => {
|
||||
@@ -1383,9 +1417,9 @@ export const useEmailStore = create<EmailState>()(
|
||||
() => apiMarkAsSpam(targets.map((e) => e.id), junk.id, junk.accountId, { markRead }),
|
||||
);
|
||||
|
||||
const removed = new Set(listed ? targets.map((e) => e.id) : []);
|
||||
const removed = new Set(listed ? targets.map(rowKeyOf) : []);
|
||||
set({
|
||||
emails: get().emails.filter((e) => !removed.has(e.id)),
|
||||
emails: get().emails.filter((e) => !removed.has(rowKeyOf(e))),
|
||||
pendingUndo: {
|
||||
kind: 'spam',
|
||||
label: targets.length === 1
|
||||
@@ -1428,9 +1462,9 @@ export const useEmailStore = create<EmailState>()(
|
||||
() => apiUndoSpam(targets.map((e) => e.id), inbox.id, inbox.accountId),
|
||||
);
|
||||
|
||||
const removed = new Set(listed ? targets.map((e) => e.id) : []);
|
||||
const removed = new Set(listed ? targets.map(rowKeyOf) : []);
|
||||
set({
|
||||
emails: get().emails.filter((e) => !removed.has(e.id)),
|
||||
emails: get().emails.filter((e) => !removed.has(rowKeyOf(e))),
|
||||
pendingUndo: {
|
||||
kind: 'spam',
|
||||
label: targets.length === 1
|
||||
@@ -1449,9 +1483,12 @@ export const useEmailStore = create<EmailState>()(
|
||||
const state = get();
|
||||
// A list spanning accounts files each row in its own account (#1082);
|
||||
// the viewer names its message's account itself.
|
||||
const spanning = viewed ? [] : spanningRows(state, [emailId]).slice(0, 1);
|
||||
const spanning = viewed ? [] : spanningRows(state, [emailId]);
|
||||
if (spanning.length > 0) return fileAcrossAccounts('move', spanning, toMailboxId);
|
||||
const { email, listed } = actionTarget(state, emailId, viewed);
|
||||
if (isGoneSpanningRow(emailId, email)) return;
|
||||
// The list row it is, by `rowKeyOf`: ids repeat across accounts.
|
||||
const rowKey = email ? rowKeyOf(email) : emailId;
|
||||
const from = refFor(state.mailboxes, fromMailboxId);
|
||||
const to = refFor(state.mailboxes, toMailboxId);
|
||||
assertViewedAccount(viewed, from, to);
|
||||
@@ -1466,7 +1503,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
set({ error: err instanceof Error ? err.message : t('notifications.move_failed', 'Move failed') });
|
||||
return;
|
||||
}
|
||||
set({ emails: get().emails.filter((e) => e.id !== emailId) });
|
||||
if (listed) set({ emails: get().emails.filter((e) => rowKeyOf(e) !== rowKey) });
|
||||
dropFromCache([emailId], from.accountId);
|
||||
return;
|
||||
}
|
||||
@@ -1477,7 +1514,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
{ kind: 'mailboxes', emailId, accountId: from.accountId, mailboxIds: target },
|
||||
() => moveEmail(emailId, from.id, to.id, from.accountId),
|
||||
);
|
||||
if (listed) set({ emails: get().emails.filter((e) => e.id !== emailId) });
|
||||
if (listed) set({ emails: get().emails.filter((e) => rowKeyOf(e) !== rowKey) });
|
||||
patchCache(emailId, { mailboxIds: target }, from.accountId);
|
||||
|
||||
if (email && original) {
|
||||
@@ -1500,12 +1537,13 @@ export const useEmailStore = create<EmailState>()(
|
||||
const state = get();
|
||||
// A list spanning accounts files each row in its own account (#1082);
|
||||
// the viewer names its message's account itself.
|
||||
const spanning = viewed ? [] : spanningRows(state, [emailId]).slice(0, 1);
|
||||
const spanning = viewed ? [] : spanningRows(state, [emailId]);
|
||||
if (spanning.length > 0) return fileAcrossAccounts('archive', spanning);
|
||||
// The viewer's copy stands in for a message the list doesn't hold
|
||||
// (unified inbox, notification, deep link).
|
||||
const { email, listed } = actionTarget(state, emailId, viewed);
|
||||
if (!email) return;
|
||||
const rowKey = rowKeyOf(email);
|
||||
|
||||
// Archive into the *same account's* Archive folder — a shared mailbox's
|
||||
// messages can't be filed into the user's own.
|
||||
@@ -1540,7 +1578,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
);
|
||||
|
||||
set({
|
||||
...(listed ? { emails: get().emails.filter((e) => e.id !== emailId) } : {}),
|
||||
...(listed ? { emails: get().emails.filter((e) => rowKeyOf(e) !== rowKey) } : {}),
|
||||
pendingUndo: {
|
||||
kind: 'archive',
|
||||
label: t('notifications.email_archived', 'Email archived'),
|
||||
@@ -1563,9 +1601,11 @@ export const useEmailStore = create<EmailState>()(
|
||||
const state = get();
|
||||
// A list spanning accounts files each row in its own account (#1082);
|
||||
// the viewer names its message's account itself.
|
||||
const spanning = viewed ? [] : spanningRows(state, [emailId]).slice(0, 1);
|
||||
const spanning = viewed ? [] : spanningRows(state, [emailId]);
|
||||
if (spanning.length > 0) return fileAcrossAccounts('delete', spanning);
|
||||
const { email, listed } = actionTarget(state, emailId, viewed);
|
||||
if (isGoneSpanningRow(emailId, email)) return;
|
||||
const rowKey = email ? rowKeyOf(email) : emailId;
|
||||
const original = email ? { ...email.mailboxIds } : null;
|
||||
const settings = useSettingsStore.getState();
|
||||
const trash = refFor(state.mailboxes, trashMailboxId);
|
||||
@@ -1616,7 +1656,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
patchCache(emailId, { mailboxIds: target }, source.accountId);
|
||||
}
|
||||
}
|
||||
if (listed) set({ emails: get().emails.filter((e) => e.id !== emailId) });
|
||||
if (listed) set({ emails: get().emails.filter((e) => rowKeyOf(e) !== rowKey) });
|
||||
|
||||
// Permanent destroy can't be undone - skip the snackbar so we don't
|
||||
// promise an undo we can't deliver.
|
||||
@@ -1646,9 +1686,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
const archiveMailbox = findArchiveMailbox(scoped);
|
||||
if (!archiveMailbox) return;
|
||||
const archive = refFor(state.mailboxes, archiveMailbox.id);
|
||||
const targets = state.emails.filter(
|
||||
(e) => emailIds.includes(e.id) && !e.mailboxIds?.[archive.id],
|
||||
);
|
||||
const targets = rowsByKey(state, emailIds).filter((e) => !e.mailboxIds?.[archive.id]);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const mode = useSettingsStore.getState().archiveMode;
|
||||
@@ -1673,9 +1711,9 @@ export const useEmailStore = create<EmailState>()(
|
||||
),
|
||||
);
|
||||
|
||||
const removed = new Set(targets.map((e) => e.id));
|
||||
const removed = new Set(targets.map(rowKeyOf));
|
||||
set({
|
||||
emails: get().emails.filter((e) => !removed.has(e.id)),
|
||||
emails: get().emails.filter((e) => !removed.has(rowKeyOf(e))),
|
||||
pendingUndo: {
|
||||
kind: 'archive',
|
||||
label: targets.length === 1
|
||||
@@ -1692,14 +1730,14 @@ export const useEmailStore = create<EmailState>()(
|
||||
},
|
||||
|
||||
moveEmailsToMailbox: async (emailIds, toMailboxId) => {
|
||||
const { emails, currentMailboxId, mailboxes } = get();
|
||||
const { currentMailboxId, mailboxes } = get();
|
||||
// A list spanning accounts files each row in its own account (#1082).
|
||||
const spanning = spanningRows(get(), emailIds);
|
||||
if (spanning.length > 0) return fileAcrossAccounts('move', spanning, toMailboxId);
|
||||
if (!currentMailboxId || toMailboxId === currentMailboxId) return;
|
||||
const source = refFor(mailboxes, currentMailboxId);
|
||||
const to = refFor(mailboxes, toMailboxId);
|
||||
const targets = emails.filter((e) => emailIds.includes(e.id));
|
||||
const targets = rowsByKey(get(), emailIds);
|
||||
if (targets.length === 0) return;
|
||||
// See moveToMailbox: one Email/set can't span two accounts — copy+delete.
|
||||
if (source.accountId !== to.accountId) {
|
||||
@@ -1709,8 +1747,8 @@ export const useEmailStore = create<EmailState>()(
|
||||
set({ error: err instanceof Error ? err.message : t('notifications.move_failed', 'Move failed') });
|
||||
return;
|
||||
}
|
||||
const moved = new Set(targets.map((e) => e.id));
|
||||
set({ emails: get().emails.filter((e) => !moved.has(e.id)) });
|
||||
const moved = new Set(targets.map(rowKeyOf));
|
||||
set({ emails: get().emails.filter((e) => !moved.has(rowKeyOf(e))) });
|
||||
dropFromCache(targets.map((e) => e.id), source.accountId);
|
||||
return;
|
||||
}
|
||||
@@ -1727,13 +1765,13 @@ export const useEmailStore = create<EmailState>()(
|
||||
() => apiMoveEmails(targets.map((e) => e.id), source.id, to.id, source.accountId),
|
||||
);
|
||||
|
||||
const removed = new Set(targets.map((e) => e.id));
|
||||
const removed = new Set(targets.map(rowKeyOf));
|
||||
for (const e of targets) {
|
||||
patchCache(e.id, { mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, to.id) }, source.accountId);
|
||||
}
|
||||
const targetName = mailboxPath(mailboxes, toMailboxId);
|
||||
set({
|
||||
emails: get().emails.filter((e) => !removed.has(e.id)),
|
||||
emails: get().emails.filter((e) => !removed.has(rowKeyOf(e))),
|
||||
pendingUndo: {
|
||||
kind: 'move',
|
||||
label: targetName
|
||||
@@ -1749,7 +1787,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
},
|
||||
|
||||
deleteEmailsBatch: async (emailIds, trashMailboxId, currentMailboxId) => {
|
||||
const { emails, mailboxes } = get();
|
||||
const { mailboxes } = get();
|
||||
// A list spanning accounts files each row in its own account (#1082).
|
||||
const spanning = spanningRows(get(), emailIds);
|
||||
if (spanning.length > 0) return fileAcrossAccounts('delete', spanning);
|
||||
@@ -1760,7 +1798,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
.find((m) => m.role === 'junk' || m.role === 'spam');
|
||||
const junkId = junkMailbox ? rawMailboxId(mailboxes, junkMailbox.id) : null;
|
||||
const inTrash = currentMailboxId === trashMailboxId;
|
||||
const targets = emails.filter((e) => emailIds.includes(e.id));
|
||||
const targets = rowsByKey(get(), emailIds);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
// Split into permanent-destroy vs move-to-trash following the same policy
|
||||
@@ -1825,8 +1863,8 @@ export const useEmailStore = create<EmailState>()(
|
||||
}, source.accountId);
|
||||
}
|
||||
|
||||
const removed = new Set(targets.map((e) => e.id));
|
||||
set({ emails: get().emails.filter((e) => !removed.has(e.id)) });
|
||||
const removed = new Set(targets.map(rowKeyOf));
|
||||
set({ emails: get().emails.filter((e) => !removed.has(rowKeyOf(e))) });
|
||||
|
||||
// Only the moved-to-trash items are recoverable; destroyed ones are gone.
|
||||
if (toTrash.length > 0) {
|
||||
@@ -1851,7 +1889,6 @@ export const useEmailStore = create<EmailState>()(
|
||||
// One Email/set per account the selection spans (#1082); the viewer's
|
||||
// message goes to the account it names.
|
||||
const groups = viewed ? [{ accountId: viewed.accountId, emails: targets }] : groupByAccount(state, targets);
|
||||
const ids = targets.map((e) => e.id);
|
||||
const patch = { [token]: on ? true : null };
|
||||
await applyOrQueueBatch(
|
||||
groups.flatMap(({ accountId, emails }) => emails.map((e): OutboxOp => ({
|
||||
@@ -1866,15 +1903,15 @@ export const useEmailStore = create<EmailState>()(
|
||||
}
|
||||
},
|
||||
);
|
||||
const touched = new Set(listed ? ids : []);
|
||||
const touched = new Set(listed ? targets.map(rowKeyOf) : []);
|
||||
set({
|
||||
emails: get().emails.map((e) =>
|
||||
touched.has(e.id) ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
|
||||
touched.has(rowKeyOf(e)) ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e,
|
||||
),
|
||||
// Reading inside Unread, unstarring inside Starred or untagging inside
|
||||
// that tag's view keeps the rows until it's re-opened.
|
||||
...(listed && leavesView(state.filters, token, on)
|
||||
? { retainedIds: retain(get().retainedIds, ids) }
|
||||
? { retainedIds: retain(get().retainedIds, [...touched]) }
|
||||
: {}),
|
||||
});
|
||||
for (const { accountId, emails } of groups) {
|
||||
@@ -1963,9 +2000,9 @@ export const useEmailStore = create<EmailState>()(
|
||||
if (spanned.length > 0) {
|
||||
// Rows of a list spanning accounts go back while that list is shown.
|
||||
if (spansAccounts(get())) {
|
||||
const shown = new Set(emails.map((e) => accountScopedId(e, e.id)));
|
||||
const shown = new Set(emails.map(rowKeyOf));
|
||||
const restored = spanned
|
||||
.filter((it) => !shown.has(accountScopedId(it.email, it.email.id)))
|
||||
.filter((it) => !shown.has(rowKeyOf(it.email)))
|
||||
.map((it) => ({
|
||||
...it.email,
|
||||
mailboxIds: it.originalMailboxIds,
|
||||
@@ -2286,7 +2323,7 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
|
||||
}
|
||||
for (const update of cacheUpdates) update();
|
||||
|
||||
const goneKeys = new Set(gone.map((e) => accountScopedId(e, e.id)));
|
||||
const goneKeys = new Set(gone.map(rowKeyOf));
|
||||
const count = items.length;
|
||||
const targetName = toMailboxId ? mailboxPath(state.mailboxes, toMailboxId) : undefined;
|
||||
const label = action === 'archive'
|
||||
@@ -2303,7 +2340,7 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
|
||||
: t('email_list.emails_moved_to', `${count} emails moved to ${targetName}`, { count, mailbox: targetName }))
|
||||
: t('notifications.emails_moved', 'Emails moved');
|
||||
set({
|
||||
emails: get().emails.filter((e) => !goneKeys.has(accountScopedId(e, e.id))),
|
||||
emails: get().emails.filter((e) => !goneKeys.has(rowKeyOf(e))),
|
||||
...(count > 0
|
||||
? {
|
||||
pendingUndo: {
|
||||
|
||||
Reference in New Issue
Block a user