mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: keep the sender name readable next to several tags or a folder chip
This commit is contained in:
@@ -2,6 +2,7 @@ import { Profiler } from 'react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ThreadListItem } from '../thread-list-item';
|
||||
import { TagDisplayContext, type TagDisplay } from '@/hooks/use-tag-display';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
@@ -142,3 +143,45 @@ describe('ThreadListItem folder chip in search results (#1172)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem sender line in a narrow list', () => {
|
||||
const display: TagDisplay = { variant: 'badge', placement: 'sender', tight: true };
|
||||
const tagged = (keywords: Record<string, boolean>) => [makeEmail({ keywords: { $seen: true, ...keywords } })];
|
||||
const renderIn = (value: TagDisplay, emails: Email[]) => {
|
||||
const [thread] = groupEmailsByThread(emails);
|
||||
return render(
|
||||
<TagDisplayContext.Provider value={value}>
|
||||
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={noop} onEmailSelect={noop} />
|
||||
</TagDisplayContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split', interfaceLayout: 'default', showColorNames: false });
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: 'inbox', isUnifiedView: false, selectedKeyword: null, searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: 'archive' });
|
||||
});
|
||||
|
||||
const dots = (c: HTMLElement) => c.querySelectorAll('[data-tag-dot]').length;
|
||||
|
||||
it('names a single tag', () => {
|
||||
const { container } = renderIn(display, tagged({ '$label:red': true }));
|
||||
expect(dots(container)).toBe(0);
|
||||
});
|
||||
|
||||
it('turns several tags into dots so the name keeps its room', () => {
|
||||
const { container } = renderIn(display, tagged({ '$label:red': true, '$label:blue': true }));
|
||||
expect(dots(container)).toBe(2);
|
||||
});
|
||||
|
||||
it('turns a tag into a dot next to the folder chip', () => {
|
||||
useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: '' });
|
||||
const { container } = renderIn(display, tagged({ '$label:red': true }));
|
||||
expect(dots(container)).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the names while the list has room', () => {
|
||||
const { container } = renderIn({ ...display, tight: false }, tagged({ '$label:red': true, '$label:blue': true }));
|
||||
expect(dots(container)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -310,10 +310,10 @@ function describeAccount(label: string | undefined, account: AccountEntry | unde
|
||||
* aggregate "All …" views (All Mail / unified / cross-account) where rows come
|
||||
* from different folders. `email.sourceFolder` is stamped at fetch time.
|
||||
*/
|
||||
function SourceFolderTag({ name }: { name: string }) {
|
||||
function SourceFolderTag({ name, tight }: { name: string; tight?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex max-w-[8rem] shrink-0 items-center gap-1 truncate rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground"
|
||||
className={cn("inline-flex shrink-0 items-center gap-1 truncate rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground", tight ? "max-w-[4.5rem]" : "max-w-[8rem]")}
|
||||
title={name}
|
||||
>
|
||||
<Folder className="h-3 w-3 shrink-0" />
|
||||
@@ -451,7 +451,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const formatAddress = useOwnDomainAddress();
|
||||
const tagScope = useEmailTagScope(email);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const { variant: tagVariant, placement: tagPlacement, tight: senderLineTight } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
|
||||
// The tint replaces the dot, and the dot can be switched off on its own (#912).
|
||||
@@ -845,7 +845,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</>
|
||||
)}
|
||||
{showPaperclip && <StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} tight={senderLineTight} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className={cn("inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300", columns && "max-w-28")}
|
||||
@@ -894,7 +894,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} scope={tagScope} />
|
||||
<TagBadge key={id} tagId={id} variant={senderLineTight && (showSourceFolder || tagIds.length > 1) ? 'dot' : tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
@@ -923,7 +923,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} tight={senderLineTight} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[0.6875rem] font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
@@ -1181,7 +1181,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
|
||||
const tagScope = useEmailTagScope(latestEmail);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const { variant: tagVariant, placement: tagPlacement, tight: senderLineTight } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
|
||||
// The tint replaces the dot, and the dot can be switched off on its own (#912).
|
||||
@@ -1616,7 +1616,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</>
|
||||
)}
|
||||
{hasAttachment && <StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} tight={senderLineTight} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className={cn("inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300", columns && "max-w-28")}
|
||||
@@ -1674,7 +1674,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} scope={tagScope} />
|
||||
<TagBadge key={id} tagId={id} variant={senderLineTight && (showSourceFolder || tagIds.length > 1) ? 'dot' : tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -1702,7 +1702,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} tight={senderLineTight} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[0.6875rem] font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
|
||||
@@ -18,14 +18,22 @@ const TAG_BESIDE_SUBJECT_MIN_WIDTH = 560;
|
||||
*/
|
||||
const TAG_NAME_MIN_WIDTH = 320;
|
||||
|
||||
/**
|
||||
* Below this, a sender line with several tags or a folder chip leaves the
|
||||
* sender's name a letter or two, so the tags give way to their dots.
|
||||
*/
|
||||
const SENDER_LINE_TIGHT_WIDTH = 480;
|
||||
|
||||
export interface TagDisplay {
|
||||
/** Whether a tag is named or shown as colour alone. */
|
||||
variant: TagBadgeVariant;
|
||||
/** Which line of a multi-line row the tags belong on. */
|
||||
placement: "subject" | "sender";
|
||||
/** The sender line can't also hold a crowd of named tags and chips. */
|
||||
tight: boolean;
|
||||
}
|
||||
|
||||
const NAMED_BESIDE_SUBJECT: TagDisplay = { variant: "badge", placement: "subject" };
|
||||
const NAMED_BESIDE_SUBJECT: TagDisplay = { variant: "badge", placement: "subject", tight: false };
|
||||
|
||||
/**
|
||||
* How message rows should draw their tags.
|
||||
@@ -65,6 +73,7 @@ export function useMeasuredTagDisplay(ref: RefObject<HTMLElement | null>): TagDi
|
||||
return {
|
||||
variant: width >= TAG_NAME_MIN_WIDTH ? "badge" : "dot",
|
||||
placement: width >= TAG_BESIDE_SUBJECT_MIN_WIDTH ? "subject" : "sender",
|
||||
tight: width < SENDER_LINE_TIGHT_WIDTH,
|
||||
};
|
||||
}, [width]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user