diff --git a/src/lib/__tests__/list-sender.test.ts b/src/lib/__tests__/list-sender.test.ts index 98b785b8..5eebf75f 100644 --- a/src/lib/__tests__/list-sender.test.ts +++ b/src/lib/__tests__/list-sender.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; -import { listAddressLabel } from '../list-sender'; +import { listAddressLabel, rowShowsRecipient } from '../list-sender'; import { toUnicodeEmailOnOwnDomains } from '../idn'; // A sender with no display name on one of the user's own IDN domains showed @@ -42,3 +42,40 @@ describe('list rows', () => { expect(unified).not.toMatch(/\.name \|\| [\w.?[\]]*\.email/); }); }); + +// A search started in Sent spans every folder: Inbox hits showed their +// recipient (the user) instead of their sender. +describe('rowShowsRecipient', () => { + const OWN = [ + { id: 'inb', role: 'inbox' }, + { id: 'snt', role: 'sent' }, + { id: 'drf', role: 'drafts' }, + ]; + const SHARED = [ + { id: 'grp-1:inb', originalId: 'inb', role: 'inbox' }, + { id: 'grp-1:snt', originalId: 'snt', role: 'sent' }, + ]; + + it('follows the open folder for its own list', () => { + expect(rowShowsRecipient({ inb: true }, [], 'sent', 'current')).toBe(true); + expect(rowShowsRecipient({ snt: true }, [], 'inbox', 'current')).toBe(false); + }); + + it("follows each row's own folders in a search over every folder", () => { + expect(rowShowsRecipient({ inb: true }, OWN, 'sent', 'all')).toBe(false); + expect(rowShowsRecipient({ snt: true }, OWN, 'sent', 'all')).toBe(true); + expect(rowShowsRecipient({ drf: true }, OWN, 'inbox', 'all')).toBe(true); + expect(rowShowsRecipient({ inb: true }, OWN, 'sent', 'inb')).toBe(false); + }); + + it("matches a shared account's folders on their raw ids", () => { + expect(rowShowsRecipient({ snt: true }, SHARED, 'sent', 'all')).toBe(true); + expect(rowShowsRecipient({ inb: true }, SHARED, 'sent', 'all')).toBe(false); + }); + + it('is wired into the list rows', () => { + const list = fs.readFileSync(path.resolve(__dirname, '..', '..', 'screens/EmailListScreen.tsx'), 'utf8'); + expect(list).toMatch(/showRecipient=\{showRecipientFor\(item\)\}/); + expect(list).not.toMatch(/const showRecipient = currentRole === 'sent'/); + }); +}); diff --git a/src/lib/list-sender.ts b/src/lib/list-sender.ts index 96582684..543c36ac 100644 --- a/src/lib/list-sender.ts +++ b/src/lib/list-sender.ts @@ -11,3 +11,21 @@ export function listAddressLabel( ): string { return address?.name || (address?.email ? formatAddress(address.email) : '') || unknown; } + +/** + * Whether a list row names its recipient instead of its sender. Sent and + * Drafts name the recipient (webmail 1.4.12). A search started there runs + * over every folder unless it is narrowed (`folderScope` other than + * 'current'), so the open folder says nothing about a row: the row's own + * folders decide, matched on each folder's raw id. `rowMailboxes` are the + * folders of the row's account. + */ +export function rowShowsRecipient( + mailboxIds: Record | undefined, + rowMailboxes: ReadonlyArray<{ id: string; originalId?: string; role?: string | null }>, + listRole: string | null | undefined, + folderScope: string, +): boolean { + if (folderScope === 'current') return listRole === 'sent' || listRole === 'drafts'; + return rowMailboxes.some((m) => (m.role === 'sent' || m.role === 'drafts') && !!mailboxIds?.[m.originalId ?? m.id]); +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index f0076d2e..bd6b9bfa 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -53,14 +53,14 @@ import { useContactsStore } from '../stores/contacts-store'; import { useOutboxStore } from '../stores/outbox-store'; import { toast } from '../stores/toast-store'; import { getContactDisplayName } from '../lib/contact-utils'; -import { listAddressLabel } from '../lib/list-sender'; +import { listAddressLabel, rowShowsRecipient } from '../lib/list-sender'; import { useOwnDomainAddress } from '../lib/use-own-domain-address'; import { formatListDate } from '../lib/date-format'; import { singleLine } from '../lib/single-line'; import { cleanPreview } from '../lib/preview-text'; import { SearchSnippetText } from '../components/email/SearchSnippetText'; import { - findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes, + findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes, mailboxesOfAccount, } from '../lib/mailbox-tree'; import { localizeMailboxName } from '../lib/mailbox-label'; import { @@ -577,7 +577,18 @@ export default function EmailListScreen({ }, [sharedTags, keywordDefs]); const folderKeywordDefs = (currentOwnerAccountId && keywordDefsByAccount.get(currentOwnerAccountId)) || keywordDefs; const inJunk = currentRole === 'junk' || currentRole === 'spam'; - const showRecipient = currentRole === 'sent' || currentRole === 'drafts'; + // A search started in Sent runs over every folder: each row names its + // recipient only when it is in Sent or Drafts itself. + const rowScope = effectiveFolderScope(storeSearchQuery, filters, currentRole); + const showRecipientFor = React.useCallback( + (item: Email) => rowShowsRecipient( + item.mailboxIds, + rowScope === 'current' ? [] : mailboxesOfAccount(mailboxes, accountIdOfRow(item)), + currentRole, + rowScope, + ), + [rowScope, mailboxes, currentRole], + ); const formatAddress = useOwnDomainAddress(); // When threading is on, collapse same-thread emails so the list shows the @@ -896,7 +907,7 @@ export default function EmailListScreen({ item={item} threadCount={threadCountFor(item)} showPreview={showPreview} - showRecipient={showRecipient} + showRecipient={showRecipientFor(item)} formatAddress={formatAddress} tagIds={rowTagIds.get(key) ?? ''} keywordDefs={(item.jmapAccountId && keywordDefsByAccount.get(item.jmapAccountId)) || folderKeywordDefs} @@ -916,7 +927,7 @@ export default function EmailListScreen({ [ selectedIds, activeRowKey, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, swipeMode, handleRowSwipe, disableThreading, rowFlags, rowTagIds, threadCountFor, - showPreview, showRecipient, formatAddress, keywordDefsByAccount, folderKeywordDefs, inJunk, showAvatarsInJunk, + showPreview, showRecipientFor, formatAddress, keywordDefsByAccount, folderKeywordDefs, inJunk, showAvatarsInJunk, loadAttachments, openAttachment, ], );