mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: point Mail's arrows, indents and panels the right way in right-to-left languages, and keep English subjects and sizes readable there
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment node
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// R11: directional icons flip only through the global list in globals.css; a
|
||||
// local rtl: flip on top of it turns them back the wrong way (the phone
|
||||
// search's back arrow pointed into the content in Arabic). Offsets use
|
||||
// logical properties so subfolders indent on the reading side.
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const mailFiles = [
|
||||
'components/mail/mail-app.tsx',
|
||||
'components/email/email-viewer.tsx',
|
||||
'components/email/email-composer.tsx',
|
||||
'components/email/plugin-search-results.tsx',
|
||||
'components/email/thread-list-item.tsx',
|
||||
'components/layout/sidebar.tsx',
|
||||
'components/templates/template-picker.tsx',
|
||||
'components/templates/template-manager-modal.tsx',
|
||||
];
|
||||
const read = (file: string) => fs.readFileSync(path.join(root, file), 'utf-8');
|
||||
|
||||
describe('Mail in right-to-left languages', () => {
|
||||
it.each(mailFiles)('%s adds no local flip to directional icons', (file) => {
|
||||
expect(read(file)).not.toMatch(/rtl:(-scale-x-100|rotate-180)/);
|
||||
});
|
||||
|
||||
it.each(mailFiles)('%s uses no physical left offsets', (file) => {
|
||||
const code = read(file).replace(/\/\/.*$/gm, '');
|
||||
expect(code).not.toMatch(/paddingLeft:|\bleft-[03]\b|\btext-left\b|-right-1\b/);
|
||||
});
|
||||
|
||||
it('isolates subjects in the list, so an English subject reads right in Arabic', () => {
|
||||
const source = read('components/email/thread-list-item.tsx');
|
||||
expect(source.match(/<bdi>\{subjectSnippet/g)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -4385,11 +4385,11 @@ export function EmailComposer({
|
||||
// Real byte progress from the transport; see
|
||||
// ComposerAttachment.progress for who reports it.
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-0.5 bg-primary/60 transition-[width] duration-300 ease-out"
|
||||
className="absolute bottom-0 start-0 h-0.5 bg-primary/60 transition-[width] duration-300 ease-out"
|
||||
style={{ width: `${att.progress}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute bottom-0 left-0 h-0.5 bg-primary/40 animate-[indeterminate_1.5s_ease-in-out_infinite]" style={{ width: '40%' }} />
|
||||
<div className="absolute bottom-0 start-0 h-0.5 bg-primary/40 animate-[indeterminate_1.5s_ease-in-out_infinite]" style={{ width: '40%' }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -5007,7 +5007,7 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
|
||||
type="button"
|
||||
disabled={isSearchingServer}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left text-sm flex items-center gap-2 text-muted-foreground hover:bg-muted disabled:opacity-60 disabled:cursor-default",
|
||||
"w-full px-3 py-2 text-start text-sm flex items-center gap-2 text-muted-foreground hover:bg-muted disabled:opacity-60 disabled:cursor-default",
|
||||
results.length > 0 && "border-t border-border"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
|
||||
@@ -4509,11 +4509,11 @@ export function EmailViewer({
|
||||
inert={!moreMenuOpen}
|
||||
className={cn(
|
||||
"bg-background border-s border-border z-[70]",
|
||||
isPaneScoped ? "absolute inset-y-0 right-0 w-72" : "fixed inset-y-0 right-0 w-72 sm:hidden",
|
||||
isPaneScoped ? "absolute inset-y-0 end-0 w-72" : "fixed inset-y-0 end-0 w-72 sm:hidden",
|
||||
"transform",
|
||||
moreMenuSlideEnabled && "transition-transform duration-300 ease-in-out",
|
||||
"flex flex-col",
|
||||
moreMenuOpen ? "translate-x-0" : "translate-x-full"
|
||||
moreMenuOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"
|
||||
)}>
|
||||
{/* The fixed panel spans the full viewport height, so in the iOS PWA
|
||||
its header would sit under the status bar without the safe-area
|
||||
@@ -4748,7 +4748,7 @@ export function EmailViewer({
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<h1 className={cn(
|
||||
<h1 dir="auto" className={cn(
|
||||
"text-foreground break-words min-w-0",
|
||||
mvViewer ? "text-[22px] leading-7 font-normal" : "text-lg lg:text-xl font-bold tracking-tight"
|
||||
)}>
|
||||
@@ -4831,7 +4831,7 @@ export function EmailViewer({
|
||||
</span>
|
||||
{email.size > 0 && (
|
||||
<div className="text-xs text-muted-foreground/60">
|
||||
{formatFileSize(email.size)}
|
||||
<bdi>{formatFileSize(email.size)}</bdi>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -5079,7 +5079,7 @@ export function EmailViewer({
|
||||
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
<bdi>{formatFileSize(attachment.size)}</bdi>
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn(
|
||||
@@ -5145,7 +5145,7 @@ export function EmailViewer({
|
||||
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
<bdi>{formatFileSize(attachment.size)}</bdi>
|
||||
</span>
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<button
|
||||
@@ -5282,7 +5282,7 @@ export function EmailViewer({
|
||||
</span>
|
||||
{email.size > 0 && !mvMobileViewer && (
|
||||
<div className="text-xs text-muted-foreground/60">
|
||||
{formatFileSize(email.size)}
|
||||
<bdi>{formatFileSize(email.size)}</bdi>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -5593,7 +5593,7 @@ export function EmailViewer({
|
||||
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
|
||||
)}
|
||||
<Row label={t('details.size')}>
|
||||
{formatFileSize(email.size)}
|
||||
<bdi>{formatFileSize(email.size)}</bdi>
|
||||
{topMimeType && (
|
||||
<span className="text-muted-foreground"> · <span className="font-mono text-xs">{topMimeType}</span></span>
|
||||
)}
|
||||
@@ -5823,7 +5823,7 @@ export function EmailViewer({
|
||||
<div
|
||||
ref={belowHeaderGhostRef}
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 left-0 right-0 flex items-center gap-2 invisible pointer-events-none whitespace-nowrap"
|
||||
className="absolute inset-0 flex items-center gap-2 invisible pointer-events-none whitespace-nowrap"
|
||||
>
|
||||
{effectiveAttachments.map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||
@@ -5849,7 +5849,7 @@ export function EmailViewer({
|
||||
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{formatFileSize(attachment.size)}
|
||||
<bdi>{formatFileSize(attachment.size)}</bdi>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -5903,7 +5903,7 @@ export function EmailViewer({
|
||||
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
<bdi>{formatFileSize(attachment.size)}</bdi>
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn(
|
||||
@@ -5969,7 +5969,7 @@ export function EmailViewer({
|
||||
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ms-auto flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
<bdi>{formatFileSize(attachment.size)}</bdi>
|
||||
</span>
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<button
|
||||
@@ -6048,7 +6048,7 @@ export function EmailViewer({
|
||||
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
<bdi>{formatFileSize(attachment.size)}</bdi>
|
||||
</span>
|
||||
{/* Phones have no hover: the plugin menu stays visible. */}
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="-me-1 flex-shrink-0" iconClassName="w-3.5 h-3.5" />
|
||||
@@ -6114,7 +6114,7 @@ export function EmailViewer({
|
||||
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
<bdi>{formatFileSize(attachment.size)}</bdi>
|
||||
</span>
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="flex-shrink-0" iconClassName="w-3.5 h-3.5" />
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
@@ -6374,7 +6374,7 @@ export function EmailViewer({
|
||||
{isMobile && !mvMobileViewer && (
|
||||
<nav ref={bottomBarRef} className={cn(
|
||||
"z-50 bg-background border-t border-border overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
|
||||
isPaneScoped ? "absolute bottom-0 left-0 right-0" : "fixed bottom-0 left-0 right-0 sm:hidden",
|
||||
isPaneScoped ? "absolute bottom-0 inset-x-0" : "fixed bottom-0 inset-x-0 sm:hidden",
|
||||
)}>
|
||||
<div className="flex items-center overflow-x-auto mobile-scroll-hidden">
|
||||
<button
|
||||
|
||||
@@ -132,7 +132,7 @@ export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResult
|
||||
aria-controls={listId}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5 rtl:-scale-x-100" />}
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<span className="truncate">{group.heading}</span>
|
||||
<span className="font-normal">({group.results.length})</span>
|
||||
</button>
|
||||
|
||||
@@ -707,7 +707,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
'min-w-0 truncate',
|
||||
isUnread ? (mvRow ? 'font-bold text-foreground' : 'font-semibold text-foreground') : 'text-foreground/90'
|
||||
)}>
|
||||
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || '(no subject)')}
|
||||
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || '(no subject)')}</bdi>
|
||||
</span>
|
||||
<PluginRowBadges email={email} className={mvRow ? "ms-2" : undefined} />
|
||||
{(previewSnippet || inlinePreview) && (
|
||||
@@ -852,7 +852,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || "(no subject)")}
|
||||
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || "(no subject)")}</bdi>
|
||||
</span>
|
||||
<PluginRowBadges email={email} />
|
||||
</div>
|
||||
@@ -1362,7 +1362,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
'min-w-0 truncate',
|
||||
hasUnread ? (mvRow ? 'font-bold text-foreground' : 'font-semibold text-foreground') : 'text-foreground/90'
|
||||
)}>
|
||||
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || '(no subject)')}
|
||||
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || '(no subject)')}</bdi>
|
||||
</span>
|
||||
<PluginRowBadges email={latestEmail} className={mvRow ? "ms-2" : undefined} />
|
||||
{(previewSnippet || inlinePreview) && (
|
||||
@@ -1514,7 +1514,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || "(no subject)")}
|
||||
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || "(no subject)")}</bdi>
|
||||
</span>
|
||||
<PluginRowBadges email={latestEmail} />
|
||||
</div>
|
||||
|
||||
@@ -413,7 +413,7 @@ function SidebarRow({
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className="flex items-center flex-shrink-0"
|
||||
style={{ paddingLeft: leftPad }}
|
||||
style={{ paddingInlineStart: leftPad }}
|
||||
>
|
||||
{hasChildren && onExpandToggle ? (
|
||||
<button
|
||||
|
||||
@@ -4880,7 +4880,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
onClick={() => setMobileSearchOpen(false)}
|
||||
aria-label={t('sidebar.mobile.go_back')}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 rtl:-scale-x-100" />
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<span className="font-medium truncate">{t('sidebar.search_placeholder')}</span>
|
||||
</div>
|
||||
@@ -4963,7 +4963,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
|
||||
<span className="absolute -top-1 -end-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
|
||||
{activeFilterCount(searchFilters)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
|
||||
{!isCreating && !editingId && (
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
|
||||
@@ -150,7 +150,7 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
|
||||
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
|
||||
Reference in New Issue
Block a user