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.
This commit is contained in:
Linus Rath
2026-09-24 20:05:38 +02:00
parent d097ae9ec3
commit edab181e60
2 changed files with 22 additions and 13 deletions
+16 -10
View File
@@ -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<ListAttachmentOpenerHandle>(null);
const loadAttachments = React.useCallback<LoadListAttachments>((emailId, onLoad) => {
const scope = `${localAccountId ?? ''}\u0000${jmapAccountId ?? ''}`;
const loadAttachments = React.useCallback<LoadListAttachments>((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 };
+6 -3
View File
@@ -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<Map<string, Attachment[]>>;
/** 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. */