mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: keep file sizes as number then unit in right-to-left languages and write Files dates in the app's language
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
let appLocale = 'ar';
|
||||
vi.mock('@/i18n/detect-locale', () => ({ getEffectiveLocale: () => appLocale }));
|
||||
|
||||
import { FileSize, formatFullDate, formatShortDate } from '../file-item-parts';
|
||||
|
||||
// The browser here is en-US; the app's language must win.
|
||||
describe('Files list dates and sizes', () => {
|
||||
afterEach(() => { appLocale = 'ar'; });
|
||||
|
||||
it('writes dates in the app language, not the browser one', () => {
|
||||
const now = new Date('2026-10-27T12:00:00Z');
|
||||
expect(formatShortDate('2026-03-05T10:00:00Z', now)).toMatch(/مارس/);
|
||||
expect(formatFullDate('2026-03-05T10:00:00Z')).toMatch(/مارس/);
|
||||
appLocale = 'de';
|
||||
expect(formatShortDate('2026-03-05T10:00:00Z', now)).toMatch(/März/);
|
||||
});
|
||||
|
||||
it('keeps the number before the unit in a right-to-left layout', () => {
|
||||
const { container } = render(<div dir="rtl"><FileSize bytes={6861} /></div>);
|
||||
const size = container.querySelector('bdi')!;
|
||||
expect(size).toHaveAttribute('dir', 'ltr');
|
||||
expect(size.textContent).toBe('6.7 KB');
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,12 @@ import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Download, Share2, Star, StarOff, FolderOpen, Eye, SquarePen, Files } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import {
|
||||
ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions,
|
||||
FileSize, ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions,
|
||||
} from "@/components/files/file-item-parts";
|
||||
|
||||
interface FileDetailsPanelProps {
|
||||
@@ -91,7 +91,7 @@ export function FileDetailsPanel({
|
||||
<p className="text-sm text-muted-foreground">{t("folder_summary", { folders, files })}</p>
|
||||
</div>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<Row label={t("size")} value={formatFileSize(totalSize(items))} />
|
||||
<Row label={t("size")} value={<FileSize bytes={totalSize(items)} />} />
|
||||
{selection.length <= 1 && <Row label={t("path")} value={path} mono />}
|
||||
</dl>
|
||||
{selection.length <= 1 && (
|
||||
@@ -160,7 +160,7 @@ export function FileDetailsPanel({
|
||||
label={t("type")}
|
||||
value={resource.isDirectory || resource.isSymlink ? kindOf(resource, t) : (resource.contentType || kindOf(resource, t))}
|
||||
/>
|
||||
{!resource.isDirectory && !resource.isSymlink && <Row label={t("size")} value={formatFileSize(resource.contentLength)} />}
|
||||
{!resource.isDirectory && !resource.isSymlink && <Row label={t("size")} value={<FileSize bytes={resource.contentLength} />} />}
|
||||
{resource.lastModified && <Row label={t("modified")} value={formatFullDate(resource.lastModified)} />}
|
||||
<Row label={t("sharing")} value={sharing} />
|
||||
<Row label={t("path")} value={path} mono />
|
||||
@@ -170,7 +170,7 @@ export function FileDetailsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
function Row({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Share2, Users } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { getEffectiveTimeZone } from "@/lib/timezone";
|
||||
import { getEffectiveLocale } from "@/i18n/detect-locale";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import { getFileIconByName, getFolderIcon, getSymlinkIcon, isImageFile, type FileIconSize } from "@/components/files/file-icons";
|
||||
|
||||
@@ -124,17 +126,33 @@ export function ShareBadge({ resource, t, className }: {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A size that keeps its number before its unit ("6.7 KB") in a right-to-left
|
||||
* layout too, where the bidi algorithm would otherwise show "KB 6.7".
|
||||
*/
|
||||
export function FileSize({ bytes }: { bytes: number }) {
|
||||
return <bdi dir="ltr">{formatFileSize(bytes)}</bdi>;
|
||||
}
|
||||
|
||||
/** Dates follow the app's language and 12/24-hour setting, as Mail's list does, not the browser's. */
|
||||
function dateLocale(): { locale: string; hour12: boolean } {
|
||||
const locale = getEffectiveLocale();
|
||||
return { locale: locale === "en" ? "en-US" : locale, hour12: useSettingsStore.getState().timeFormat === "12h" };
|
||||
}
|
||||
|
||||
/** Full date and time, for tooltips and the details panel. */
|
||||
export function formatFullDate(value: string): string {
|
||||
if (!value) return "";
|
||||
try {
|
||||
return new Date(value).toLocaleString(undefined, {
|
||||
const { locale, hour12 } = dateLocale();
|
||||
return new Date(value).toLocaleString(locale, {
|
||||
timeZone: getEffectiveTimeZone(),
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12,
|
||||
});
|
||||
} catch {
|
||||
return value;
|
||||
@@ -149,12 +167,13 @@ export function formatShortDate(value: string, now = new Date()): string {
|
||||
const timeZone = getEffectiveTimeZone();
|
||||
const dayKey = (d: Date) => d.toLocaleDateString("en-CA", { timeZone });
|
||||
try {
|
||||
const { locale, hour12 } = dateLocale();
|
||||
if (dayKey(date) === dayKey(now)) {
|
||||
return date.toLocaleTimeString(undefined, { timeZone, hour: "2-digit", minute: "2-digit" });
|
||||
return date.toLocaleTimeString(locale, { timeZone, hour: "2-digit", minute: "2-digit", hour12 });
|
||||
}
|
||||
const sameYear = date.toLocaleDateString("en-CA", { timeZone, year: "numeric" })
|
||||
=== now.toLocaleDateString("en-CA", { timeZone, year: "numeric" });
|
||||
return date.toLocaleDateString(undefined, {
|
||||
return date.toLocaleDateString(locale, {
|
||||
timeZone,
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
|
||||
@@ -175,7 +175,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
|
||||
<span className="block truncate">{node.name}</span>
|
||||
{detail && <span className="block truncate text-xs text-muted-foreground">{detail}</span>}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{formatFileSize(node.size)}</span>
|
||||
<bdi dir="ltr" className="shrink-0 text-xs text-muted-foreground tabular-nums">{formatFileSize(node.size)}</bdi>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowUp, ArrowDown, MoreVertical, Download, Share2, Star, Check } from "@/components/icons";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import {
|
||||
ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
|
||||
FileSize, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
|
||||
type IconOptions,
|
||||
} from "@/components/files/file-item-parts";
|
||||
import type { SortDir, SortKey } from "@/components/files/files-header";
|
||||
@@ -271,7 +271,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{note && <>{note} · </>}
|
||||
{formatShortDate(resource.lastModified)}
|
||||
{!resource.isDirectory && !resource.isSymlink && <> · {formatFileSize(resource.contentLength)}</>}
|
||||
{!resource.isDirectory && !resource.isSymlink && <> · <FileSize bytes={resource.contentLength} /></>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -280,7 +280,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
|
||||
{!touch && formatShortDate(resource.lastModified)}
|
||||
</div>
|
||||
<div role="gridcell" className="hidden sm:block text-muted-foreground tabular-nums text-end">
|
||||
{!touch && (resource.isDirectory || resource.isSymlink ? "—" : formatFileSize(resource.contentLength))}
|
||||
{!touch && (resource.isDirectory || resource.isSymlink ? "—" : <FileSize bytes={resource.contentLength} />)}
|
||||
</div>
|
||||
<div role="gridcell" className="flex items-center justify-end gap-0.5 w-[4.75rem]">
|
||||
{!touch && !resource.isDirectory && !resource.isSymlink && (
|
||||
@@ -482,7 +482,7 @@ function FileTile({ resource, isSelected, isFocused, isCut, selecting, touch, ic
|
||||
</button>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground min-w-0">
|
||||
<span className="truncate">
|
||||
{resource.isSymlink ? "" : `${formatFileSize(resource.contentLength)} · `}{formatShortDate(resource.lastModified)}
|
||||
{!resource.isSymlink && <><FileSize bytes={resource.contentLength} /> · </>}{formatShortDate(resource.lastModified)}
|
||||
</span>
|
||||
<ShareBadge resource={resource} t={t} className="w-3 h-3" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user