From edab181e603e781d944c7e2e6190556e2681f86d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:01:46 +0200 Subject: [PATCH] fix: load list attachment chips from each search hit's own account #1082 The attachment chips on list rows fetched every row's parts from the account of the open folder. In an "All folders" search or a tag view the rows come from several accounts, so a hit from the team inbox asked the user's own account, which answers for another message with the same id, or not at all. A row that carries an account stamp now loads its chips, and opens an attachment from them, in its own account; the chips a row remembers are keyed by that account too. --- src/components/email/ListAttachmentChips.tsx | 26 ++++++++++++-------- src/lib/list-attachments.ts | 9 ++++--- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/components/email/ListAttachmentChips.tsx b/src/components/email/ListAttachmentChips.tsx index acd59289..9e28a425 100644 --- a/src/components/email/ListAttachmentChips.tsx +++ b/src/components/email/ListAttachmentChips.tsx @@ -9,6 +9,8 @@ import { useTypography } from '../../theme/dynamic'; import { useLocaleStore } from '../../stores/locale-store'; import { useSettingsStore } from '../../stores/settings-store'; import { useAuthStore } from '../../stores/auth-store'; +import { accountIdOfRow } from '../../stores/email-store'; +import { accountScopedId } from '../../lib/thread-utils'; import { attachmentKind, realAttachments, requestListAttachments, shortAttachmentName, type AttachmentKind, type LoadListAttachments, @@ -45,20 +47,21 @@ function iconColor(kind: AttachmentKind, c: ThemePalette): string { * opened before), else loaded lazily when it has a paperclip. */ function useListAttachments(email: Email, load?: LoadListAttachments): Attachment[] | undefined { - const [loaded, setLoaded] = React.useState<{ id: string; attachments: Attachment[] } | null>(null); + const [loaded, setLoaded] = React.useState<{ key: string; attachments: Attachment[] } | null>(null); const needsLoad = !!load && !!email.hasAttachment && !email.attachments; + // Rows of a list spanning accounts can share an id (#1082). + const key = accountScopedId(email, email.id); React.useEffect(() => { if (!needsLoad || !load) return undefined; - const id = email.id; - return load(id, (attachments) => setLoaded({ id, attachments })); + return load(email, (attachments) => setLoaded({ key, attachments })); // The row's email object is replaced on every keyword change; only a // different message needs a different answer. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [needsLoad, load, email.id]); + }, [needsLoad, load, key]); if (email.attachments) return email.attachments; - return loaded?.id === email.id ? loaded.attachments : undefined; + return loaded?.key === key ? loaded.attachments : undefined; } interface ChipsProps { @@ -227,8 +230,11 @@ export function useListRowAttachments(mailboxes: readonly Mailbox[], currentMail const jmapAccountId = current?.isShared ? current.accountId : undefined; const openerRef = React.useRef(null); - const loadAttachments = React.useCallback((emailId, onLoad) => { - const scope = `${localAccountId ?? ''}\u0000${jmapAccountId ?? ''}`; + const loadAttachments = React.useCallback((email, onLoad) => { + // A row of an "All folders" list or a tag view lives in its own account, + // whatever folder is open (#1082, #1038). + const accountId = email.jmapAccountId ? accountIdOfRow(email) : jmapAccountId; + const scope = `${localAccountId ?? ''}\u0000${accountId ?? ''}`; return requestListAttachments(scope, async (ids) => { // The request goes out through the active account's client: if the // user switched accounts meanwhile, fail (and don't cache) instead of @@ -236,12 +242,12 @@ export function useListRowAttachments(mailboxes: readonly Mailbox[], currentMail if (useAuthStore.getState().activeAccountId !== localAccountId) { throw new Error('Account switched'); } - return getEmailAttachments(ids, jmapAccountId); - }, emailId, onLoad); + return getEmailAttachments(ids, accountId); + }, email.id, onLoad); }, [localAccountId, jmapAccountId]); const openAttachment = React.useCallback((email: Email, attachment: Attachment) => { - openerRef.current?.open(email, attachment, jmapAccountId); + openerRef.current?.open(email, attachment, email.jmapAccountId ? accountIdOfRow(email) : jmapAccountId); }, [jmapAccountId]); return { loadAttachments, openAttachment, openerRef }; diff --git a/src/lib/list-attachments.ts b/src/lib/list-attachments.ts index 418951d5..675cc145 100644 --- a/src/lib/list-attachments.ts +++ b/src/lib/list-attachments.ts @@ -1,4 +1,4 @@ -import type { Attachment } from '../api/types'; +import type { Attachment, Email } from '../api/types'; /** * Attachment chips for message-list rows (webmail 0e1c34cd, #1089). @@ -50,8 +50,11 @@ type Listener = (attachments: Attachment[]) => void; /** Fetches the parts of a batch of emails from one account. */ export type FetchListAttachments = (emailIds: string[]) => Promise>; -/** Loader handed to list rows: starts a lazy fetch, returns its cancel. */ -export type LoadListAttachments = (emailId: string, onLoad: Listener) => () => void; +/** + * Loader handed to list rows: starts a lazy fetch of the row's parts from the + * account it lives in, returns its cancel. + */ +export type LoadListAttachments = (email: Email, onLoad: Listener) => () => void; interface Queue { /** Waiting for the next flush, by email id. */