feat(settings): drop user-facing time/date format toggle and derive everything from system locale via Intl.DateTimeFormat

This commit is contained in:
heaven 2026-05-03 19:27:54 +03:00
parent a1ff5db724
commit e547c466a8
19 changed files with 81 additions and 613 deletions

View file

@ -148,7 +148,7 @@ These are vojo additions on top of stock Cinny — they crossed many recent stab
**Jotai** atoms in `src/app/state/`:
- `settings.ts` — User preferences (`themeId`, `useSystemTheme`, `monochromeMode`, `hideMembershipEvents`, `hideNickAvatarEvents`, …). Persisted to `localStorage['settings']`. The `MessageLayout` enum + `messageSpacing` + `legacyUsernameColor` fields were dropped in P3c; the new `dawn-p3c-cleanup` migration in `getSettings()` strips the orphan keys from existing users' persisted JSON on first load.
- `settings.ts` — User preferences (`themeId`, `useSystemTheme`, `monochromeMode`, `hideMembershipEvents`, `hideNickAvatarEvents`, …). Persisted to `localStorage['settings']`. The `MessageLayout` enum + `messageSpacing` + `legacyUsernameColor` fields were dropped in P3c; the new `dawn-p3c-cleanup` migration in `getSettings()` strips the orphan keys from existing users' persisted JSON on first load. The user-facing `hour24Clock` / `dateFormatString` fields were removed too — both now derive from the runtime locale via `Intl.DateTimeFormat` in `utils/time.ts` (24-hour locales → `HH:mm` + `DD/MM/YYYY`; AM/PM locales → `hh:mm A` + `MM/DD/YYYY`). The `system-time-format-cleanup` migration synchronously deletes those keys on first load. **Known platform limitation**: Android's manual «Use 24-hour format» toggle in Date & Time settings is invisible to JS — `Intl` reads only CLDR locale conventions. Russian-locale users with AM/PM toggle get 24-hour format on both web and Capacitor; only a native bridge to `android.text.format.DateFormat.is24HourFormat(context)` would respect that toggle.
- `sessions.ts` — Active session
- `upload.ts` — Upload progress (in-memory)
- `room/``roomInputDrafts` (in-memory), `roomToParents`, `roomToUnread`

View file

@ -99,27 +99,6 @@
"monochrome_mode": "Monochrome Mode",
"twitter_emoji": "Twitter Emoji",
"page_zoom": "Page Zoom",
"date_time": "Date & Time",
"hour_24": "24-Hour Time Format",
"date_format": "Date Format",
"custom": "Custom",
"formatting": "Formatting",
"year": "Year",
"two_digit_year": "Two-digit year",
"four_digit_year": "Four-digit year",
"month": "Month",
"the_month": "The month",
"two_digit_month": "Two-digit month",
"short_month_name": "Short month name",
"full_month_name": "Full month name",
"day_of_month": "Day of the Month",
"day_of_month_val": "Day of the month",
"two_digit_day": "Two-digit day of the month",
"day_of_week": "Day of the Week",
"day_of_week_sunday": "Day of the week (Sunday = 0)",
"two_letter_day": "Two-letter day name",
"short_day_name": "Short day name",
"full_day_name": "Full day name",
"save": "Save",
"editor": "Editor",
"enter_newline": "ENTER for Newline",

View file

@ -99,27 +99,6 @@
"monochrome_mode": "Монохромный режим",
"twitter_emoji": "Эмодзи Twitter",
"page_zoom": "Масштаб страницы",
"date_time": "Дата и время",
"hour_24": "24-часовой формат",
"date_format": "Формат даты",
"custom": "Пользовательский",
"formatting": "Форматирование",
"year": "Год",
"two_digit_year": "Двузначный год",
"four_digit_year": "Четырёхзначный год",
"month": "Месяц",
"the_month": "Месяц",
"two_digit_month": "Двузначный месяц",
"short_month_name": "Краткое название месяца",
"full_month_name": "Полное название месяца",
"day_of_month": "День месяца",
"day_of_month_val": "День месяца",
"two_digit_day": "Двузначный день месяца",
"day_of_week": "День недели",
"day_of_week_sunday": "День недели (воскресенье = 0)",
"two_letter_day": "Двухбуквенное название дня",
"short_day_name": "Краткое название дня",
"full_day_name": "Полное название дня",
"save": "Сохранить",
"editor": "Редактор",
"enter_newline": "ENTER для новой строки",

View file

@ -6,26 +6,17 @@ import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/ti
export type TimeProps = {
compact?: boolean;
ts: number;
hour24Clock: boolean;
dateFormatString: string;
};
/**
* Renders a formatted timestamp, supporting compact and full display modes.
* Renders a formatted timestamp using the system locale's hour and date format.
*
* Displays the time in hour:minute format if the message is from today, yesterday, or if `compact` is true.
* For older messages, it shows the date and time.
*
* @param {number} ts - The timestamp to display.
* @param {boolean} [compact=false] - If true, always show only the time.
* @param {boolean} hour24Clock - Whether to use 24-hour time format.
* @param {string} dateFormatString - Format string for the date part.
* @returns {React.ReactElement} A <Text as="time"> element with the formatted date/time.
* Today/yesterday/`compact` show time only; older messages show date + time.
*/
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => {
({ compact, ts, ...props }, ref) => {
const { t } = useTranslation();
const formattedTime = timeHourMinute(ts, hour24Clock);
const formattedTime = timeHourMinute(ts);
let time = '';
if (compact) {
@ -35,7 +26,7 @@ export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
} else if (yesterday(ts)) {
time = `${t('Room.yesterday')} ${formattedTime}`;
} else {
time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`;
time = `${timeDayMonYear(ts)} ${formattedTime}`;
}
return (

View file

@ -14,8 +14,6 @@ import { nameInitials } from '../../utils/common';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { useIsOneOnOne } from '../../hooks/useRoom';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { InviteUserPrompt } from '../invite-user-prompt';
export type RoomIntroProps = {
@ -51,8 +49,6 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
return (
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
<Box>
@ -80,7 +76,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
values={{
creator: creatorName,
date: timeDayMonthYear(ts),
time: timeHourMinute(ts, hour24Clock),
time: timeHourMinute(ts),
}}
components={{ bold: <b /> }}
/>

View file

@ -3,9 +3,14 @@ import { Menu, Box, Text, Chip } from 'folds';
import dayjs from 'dayjs';
import * as css from './styles.css';
import { PickerColumn } from './PickerColumn';
import { hour12to24, hour24to12, hoursToMs, inSameDay, minutesToMs } from '../../utils/time';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
SYSTEM_HOUR_24,
hour12to24,
hour24to12,
hoursToMs,
inSameDay,
minutesToMs,
} from '../../utils/time';
type TimePickerProps = {
min: number;
@ -15,11 +20,9 @@ type TimePickerProps = {
};
export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
({ min, max, value, onChange }, ref) => {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const hour24 = dayjs(value).hour();
const selectedHour = hour24Clock ? hour24 : hour24to12(hour24);
const selectedHour = SYSTEM_HOUR_24 ? hour24 : hour24to12(hour24);
const selectedMinute = dayjs(value).minute();
const selectedPM = hour24 >= 12;
@ -28,7 +31,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
};
const handleHour = (hour: number) => {
const seconds = hoursToMs(hour24Clock ? hour : hour12to24(hour, selectedPM));
const seconds = hoursToMs(SYSTEM_HOUR_24 ? hour : hour12to24(hour, selectedPM));
const lastSeconds = hoursToMs(hour24);
const newValue = value + (seconds - lastSeconds);
handleSubmit(newValue);
@ -63,7 +66,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
<Menu className={css.PickerMenu} ref={ref}>
<Box direction="Row" gap="200" className={css.PickerContainer}>
<PickerColumn title="Hour">
{hour24Clock
{SYSTEM_HOUR_24
? Array.from(Array(24).keys()).map((hour) => (
<Chip
key={hour}
@ -120,7 +123,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
</Chip>
))}
</PickerColumn>
{!hour24Clock && (
{!SYSTEM_HOUR_24 && (
<PickerColumn title="Period">
<Chip
size="500"

View file

@ -6,8 +6,6 @@ import { SettingTile } from '../setting-tile';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { BreakWord } from '../../styles/Text.css';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { timeDayMonYear, timeHourMinute } from '../../utils/time';
type UserKickAlertProps = {
@ -16,11 +14,8 @@ type UserKickAlertProps = {
ts?: number;
};
export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
const time = ts ? timeHourMinute(ts) : undefined;
const date = ts ? timeDayMonYear(ts) : undefined;
return (
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
@ -66,11 +61,8 @@ type UserBanAlertProps = {
export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) {
const mx = useMatrixClient();
const room = useRoom();
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
const time = ts ? timeHourMinute(ts) : undefined;
const date = ts ? timeDayMonYear(ts) : undefined;
const [unbanState, unban] = useAsyncCallback<undefined, Error, []>(
useCallback(async () => {
@ -141,11 +133,8 @@ type UserInviteAlertProps = {
export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) {
const mx = useMatrixClient();
const room = useRoom();
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
const time = ts ? timeHourMinute(ts) : undefined;
const date = ts ? timeDayMonYear(ts) : undefined;
const [kickState, kick] = useAsyncCallback<undefined, Error, []>(
useCallback(async () => {

View file

@ -60,9 +60,6 @@ export function MessageSearch({
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const searchInputRef = useRef<HTMLInputElement>(null);
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
const [searchParams, setSearchParams] = useSearchParams();
@ -299,8 +296,6 @@ export function MessageSearch({
urlPreview={urlPreview}
onOpen={navigateRoom}
legacyUsernameColor={isOneOnOneRoom(groupRoom)}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</VirtualTile>
);

View file

@ -61,8 +61,6 @@ type SearchResultGroupProps = {
urlPreview?: boolean;
onOpen: (roomId: string, eventId: string) => void;
legacyUsernameColor?: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
export function SearchResultGroup({
room,
@ -72,8 +70,6 @@ export function SearchResultGroup({
urlPreview,
onOpen,
legacyUsernameColor,
hour24Clock,
dateFormatString,
}: SearchResultGroupProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
@ -292,11 +288,7 @@ export function SearchResultGroup({
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Time
ts={event.origin_server_ts}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
<Time ts={event.origin_server_ts} />
</Box>
<Box shrink="No" gap="200" alignItems="Center">
<Chip

View file

@ -260,7 +260,7 @@ const deriveRoomPreview = (room: Room): RoomPreview => {
return { ts: room.getLastActiveTimestamp() || undefined, text: '' };
};
const formatRowTime = (ts: number, hour24Clock: boolean): string => {
const formatRowTime = (ts: number): string => {
const now = Date.now();
const diff = now - ts;
const day = 24 * 60 * 60 * 1000;
@ -270,7 +270,7 @@ const formatRowTime = (ts: number, hour24Clock: boolean): string => {
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
if (sameDay) return timeHourMinute(ts, hour24Clock);
if (sameDay) return timeHourMinute(ts);
if (diff < 7 * day) {
return date.toLocaleDateString(undefined, { weekday: 'short' });
}
@ -287,7 +287,6 @@ export function DmStreamRow({ room, selected, notificationMode, linkPath }: DmSt
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
@ -302,7 +301,7 @@ export function DmStreamRow({ room, selected, notificationMode, linkPath }: DmSt
const preview = deriveRoomPreview(room);
const previewText = preview.text;
const previewTs = preview.ts;
const timeLabel = previewTs ? formatRowTime(previewTs, hour24Clock) : '';
const timeLabel = previewTs ? formatRowTime(previewTs) : '';
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault();

View file

@ -435,9 +435,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const ignoredUsersList = useIgnoredUsers();
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
@ -1133,8 +1130,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
memberPowerTag={getMemberPowerTag(senderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={isOneOnOne}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
>
@ -1235,8 +1230,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={isOneOnOne}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
>
@ -1349,8 +1342,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={isOneOnOne}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
>
@ -1397,8 +1388,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Time
ts={mEvent.getTs()}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
@ -1447,8 +1436,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Time
ts={mEvent.getTs()}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
@ -1498,8 +1485,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Time
ts={mEvent.getTs()}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
@ -1549,8 +1534,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Time
ts={mEvent.getTs()}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
@ -1608,8 +1591,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Time
ts={mEvent.getTs()}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
@ -1656,8 +1637,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Time
ts={mEvent.getTs()}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
@ -1706,8 +1685,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Time
ts={mEvent.getTs()}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);

View file

@ -30,8 +30,6 @@ import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time';
import { DatePicker, TimePicker } from '../../../components/time-date';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
type JumpToTimeProps = {
onCancel: () => void;
@ -49,8 +47,6 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
const createTs = useMemo(() => createStateEvent?.getTs() ?? 0, [createStateEvent]);
const [ts, setTs] = useState(() => Date.now());
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [timePickerCords, setTimePickerCords] = useState<RectCords>();
const [datePickerCords, setDatePickerCords] = useState<RectCords>();
@ -131,7 +127,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
after={<Icon size="50" src={Icons.ChevronBottom} />}
onClick={handleTimePicker}
>
<Text size="B300">{timeHourMinute(ts, hour24Clock)}</Text>
<Text size="B300">{timeHourMinute(ts)}</Text>
</Chip>
<PopOut
anchor={timePickerCords}

View file

@ -668,8 +668,6 @@ export type MessageProps = {
memberPowerTag?: MemberPowerTag;
accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean;
hour24Clock: boolean;
dateFormatString: string;
streamRailStart?: boolean;
streamRailEnd?: boolean;
};
@ -699,8 +697,6 @@ export const Message = as<'div', MessageProps>(
memberPowerTag,
accessibleTagColors,
legacyUsernameColor,
hour24Clock,
dateFormatString,
streamRailStart,
streamRailEnd,
children,
@ -1047,14 +1043,7 @@ export const Message = as<'div', MessageProps>(
</div>
)}
<StreamLayout
time={
<Time
ts={mEvent.getTs()}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
}
time={<Time ts={mEvent.getTs()} compact />}
dotColor={dot.color}
dotOpacity={dot.opacity}
isOwn={isOwnMessage}

View file

@ -97,8 +97,6 @@ type PinnedMessageProps = {
getMemberPowerTag: GetMemberPowerTag;
accessibleTagColors: Map<string, string>;
legacyUsernameColor: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
function PinnedMessage({
room,
@ -109,8 +107,6 @@ function PinnedMessage({
getMemberPowerTag,
accessibleTagColors,
legacyUsernameColor,
hour24Clock,
dateFormatString,
}: PinnedMessageProps) {
const { t } = useTranslation();
const pinnedEvent = useRoomEvent(room, eventId);
@ -221,11 +217,7 @@ function PinnedMessage({
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Time
ts={pinnedEvent.getTs()}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
<Time ts={pinnedEvent.getTs()} />
</Box>
{renderOptions()}
</Box>
@ -279,9 +271,6 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
const isOneOnOne = useIsOneOnOne();
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const { navigateRoom } = useRoomNavigate();
const scrollRef = useRef<HTMLDivElement>(null);
@ -499,8 +488,6 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={isOneOnOne}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</SequenceCard>
</VirtualTile>

View file

@ -28,8 +28,6 @@ import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { LogoutDialog } from '../../../components/LogoutDialog';
import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
export function DeviceTilePlaceholder() {
return (
@ -45,8 +43,6 @@ export function DeviceTilePlaceholder() {
function DeviceActiveTime({ ts }: { ts: number }) {
const { t } = useTranslation();
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
return (
<Text className={BreakWord} size="T200">
@ -56,8 +52,8 @@ function DeviceActiveTime({ ts }: { ts: number }) {
<>
{today(ts) && t('Settings.today')}
{yesterday(ts) && t('Settings.yesterday')}
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts, dateFormatString)}{' '}
{timeHourMinute(ts, hour24Clock)}
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts)}{' '}
{timeHourMinute(ts)}
</>
</Text>
);

View file

@ -1,25 +1,10 @@
import React, {
ChangeEventHandler,
FormEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useEffect,
useState,
} from 'react';
import dayjs from 'dayjs';
import React, { ChangeEventHandler, KeyboardEventHandler, useState } from 'react';
import {
Box,
Button,
config,
Header,
Icon,
IconButton,
Icons,
Input,
Menu,
MenuItem,
PopOut,
RectCords,
Scroll,
Switch,
Text,
@ -27,16 +12,13 @@ import {
} from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { useTranslation } from 'react-i18next';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings';
import { DateFormat, settingsAtom } from '../../../state/settings';
import { settingsAtom } from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent';
import { stopPropagation } from '../../../utils/keyboard';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { SequenceCardStyle } from '../styles.css';
function ThemeSelect() {
@ -128,375 +110,6 @@ function Appearance() {
);
}
type DateHintProps = {
hasChanges: boolean;
handleReset: () => void;
};
function DateHint({ hasChanges, handleReset }: DateHintProps) {
const { t } = useTranslation();
const [anchor, setAnchor] = useState<RectCords>();
const categoryPadding = { padding: config.space.S200, paddingTop: 0 };
const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
setAnchor(evt.currentTarget.getBoundingClientRect());
};
return (
<PopOut
anchor={anchor}
position="Top"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxHeight: '85vh', overflowY: 'auto' }}>
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
<Text size="L400">{t('Settings.formatting')}</Text>
</Header>
<Box direction="Column">
<Box style={categoryPadding} direction="Column">
<Header size="300">
<Text size="L400">{t('Settings.year')}</Text>
</Header>
<Box direction="Column" tabIndex={0} gap="100">
<Text size="T300">
YY
<Text as="span" size="Inherit" priority="300">
{': '}
{t('Settings.two_digit_year')}
</Text>{' '}
</Text>
<Text size="T300">
YYYY
<Text as="span" size="Inherit" priority="300">
{': '}
{t('Settings.four_digit_year')}
</Text>
</Text>
</Box>
</Box>
<Box style={categoryPadding} direction="Column">
<Header size="300">
<Text size="L400">{t('Settings.month')}</Text>
</Header>
<Box direction="Column" tabIndex={0} gap="100">
<Text size="T300">
M
<Text as="span" size="Inherit" priority="300">
{': '}
{t('Settings.the_month')}
</Text>
</Text>
<Text size="T300">
MM
<Text as="span" size="Inherit" priority="300">
{': '}
{t('Settings.two_digit_month')}
</Text>{' '}
</Text>
<Text size="T300">
MMM
<Text as="span" size="Inherit" priority="300">
{': '}
{t('Settings.short_month_name')}
</Text>
</Text>
<Text size="T300">
MMMM
<Text as="span" size="Inherit" priority="300">
{': '}
{t('Settings.full_month_name')}
</Text>
</Text>
</Box>
</Box>
<Box style={categoryPadding} direction="Column">
<Header size="300">
<Text size="L400">{t('Settings.day_of_month')}</Text>
</Header>
<Box direction="Column" tabIndex={0} gap="100">
<Text size="T300">
D
<Text as="span" size="Inherit" priority="300">
{': '}
{t('Settings.day_of_month_val')}
</Text>
</Text>
<Text size="T300">
DD
<Text as="span" size="Inherit" priority="300">
{': '}
{t('Settings.two_digit_day')}
</Text>
</Text>
</Box>
</Box>
<Box style={categoryPadding} direction="Column">
<Header size="300">
<Text size="L400">{t('Settings.day_of_week')}</Text>
</Header>
<Box direction="Column" tabIndex={0} gap="100">
<Text size="T300">
d
<Text as="span" size="Inherit" priority="300">
{': '}
{t('Settings.day_of_week_sunday')}
</Text>
</Text>
<Text size="T300">
dd
<Text as="span" size="Inherit" priority="300">
{': '}
{t('Settings.two_letter_day')}
</Text>
</Text>
<Text size="T300">
ddd
<Text as="span" size="Inherit" priority="300">
{': '}
{t('Settings.short_day_name')}
</Text>
</Text>
<Text size="T300">
dddd
<Text as="span" size="Inherit" priority="300">
{': '}
{t('Settings.full_day_name')}
</Text>
</Text>
</Box>
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
{hasChanges ? (
<IconButton
tabIndex={-1}
onClick={handleReset}
type="reset"
variant="Secondary"
size="300"
radii="300"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
) : (
<IconButton
tabIndex={-1}
onClick={handleOpenMenu}
type="button"
variant="Secondary"
size="300"
radii="300"
aria-pressed={!!anchor}
>
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
</IconButton>
)}
</PopOut>
);
}
type CustomDateFormatProps = {
value: string;
onChange: (format: string) => void;
};
function CustomDateFormat({ value, onChange }: CustomDateFormatProps) {
const { t } = useTranslation();
const [dateFormatCustom, setDateFormatCustom] = useState(value);
useEffect(() => {
setDateFormatCustom(value);
}, [value]);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const format = evt.currentTarget.value;
setDateFormatCustom(format);
};
const handleReset = () => {
setDateFormatCustom(value);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const target = evt.target as HTMLFormElement | undefined;
const customDateFormatInput = target?.customDateFormatInput as HTMLInputElement | undefined;
const format = customDateFormatInput?.value;
if (!format) return;
onChange(format);
};
const hasChanges = dateFormatCustom !== value;
return (
<SettingTile>
<Box as="form" onSubmit={handleSubmit} gap="200">
<Box grow="Yes" direction="Column">
<Input
required
name="customDateFormatInput"
value={dateFormatCustom}
onChange={handleChange}
maxLength={16}
autoComplete="off"
variant="Secondary"
radii="300"
style={{ paddingRight: config.space.S200 }}
after={<DateHint hasChanges={hasChanges} handleReset={handleReset} />}
/>
</Box>
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges}
type="submit"
>
<Text size="B400">{t('Settings.save')}</Text>
</Button>
</Box>
</SettingTile>
);
}
type PresetDateFormatProps = {
value: string;
onChange: (format: string) => void;
};
function PresetDateFormat({ value, onChange }: PresetDateFormatProps) {
const { t } = useTranslation();
const [menuCords, setMenuCords] = useState<RectCords>();
const dateFormatItems = useDateFormatItems();
const getDisplayDate = (format: string): string =>
format !== '' ? dayjs().format(format) : t('Settings.custom');
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (format: DateFormat) => {
onChange(format);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">
{getDisplayDate(dateFormatItems.find((i) => i.format === value)?.format ?? value)}
</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{dateFormatItems.map((item) => (
<MenuItem
key={item.format}
size="300"
variant={value === item.format ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(item.format)}
>
<Text size="T300">{getDisplayDate(item.format)}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function SelectDateFormat() {
const { t } = useTranslation();
const [dateFormatString, setDateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [selectedDateFormat, setSelectedDateFormat] = useState(dateFormatString);
const customDateFormat = selectedDateFormat === '';
const handlePresetChange = (format: string) => {
setSelectedDateFormat(format);
if (format !== '') {
setDateFormatString(format);
}
};
return (
<>
<SettingTile
title={t('Settings.date_format')}
description={customDateFormat ? dayjs().format(dateFormatString) : ''}
after={<PresetDateFormat value={selectedDateFormat} onChange={handlePresetChange} />}
/>
{customDateFormat && (
<CustomDateFormat value={dateFormatString} onChange={setDateFormatString} />
)}
</>
);
}
function DateAndTime() {
const { t } = useTranslation();
const [hour24Clock, setHour24Clock] = useSetting(settingsAtom, 'hour24Clock');
return (
<Box direction="Column" gap="100">
<Text size="L400">{t('Settings.date_time')}</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title={t('Settings.hour_24')}
after={<Switch variant="Primary" value={hour24Clock} onChange={setHour24Clock} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SelectDateFormat />
</SequenceCard>
</Box>
);
}
function Editor() {
const { t } = useTranslation();
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
@ -636,7 +249,6 @@ export function General({ requestClose }: GeneralProps) {
<PageContent>
<Box direction="Column" gap="700">
<Appearance />
<DateAndTime />
<Editor />
<Messages />
</Box>

View file

@ -1,38 +0,0 @@
import { useMemo } from 'react';
import { DateFormat } from '../state/settings';
export type DateFormatItem = {
name: string;
format: DateFormat;
};
export const useDateFormatItems = (): DateFormatItem[] =>
useMemo(
() => [
{
format: 'D MMM YYYY',
name: 'D MMM YYYY',
},
{
format: 'DD/MM/YYYY',
name: 'DD/MM/YYYY',
},
{
format: 'MM/DD/YYYY',
name: 'MM/DD/YYYY',
},
{
format: 'YYYY/MM/DD',
name: 'YYYY/MM/DD',
},
{
format: 'YYYY-MM-DD',
name: 'YYYY-MM-DD',
},
{
format: '',
name: 'Custom',
},
],
[]
);

View file

@ -1,13 +1,6 @@
import { atom } from 'jotai';
const STORAGE_KEY = 'settings';
export type DateFormat =
| 'D MMM YYYY'
| 'DD/MM/YYYY'
| 'MM/DD/YYYY'
| 'YYYY/MM/DD'
| 'YYYY-MM-DD'
| '';
export interface Settings {
themeId?: string;
@ -32,9 +25,6 @@ export interface Settings {
isNotificationSounds: boolean;
inviteSpamFilter: boolean;
hour24Clock: boolean;
dateFormatString: string;
developerTools: boolean;
migrationsApplied?: Record<string, boolean>;
@ -42,6 +32,7 @@ export interface Settings {
const DAWN_MIGRATION_KEY = 'dawn-redesign-v1';
const P3C_CLEANUP_KEY = 'dawn-p3c-cleanup';
const SYSTEM_TIME_FORMAT_CLEANUP_KEY = 'system-time-format-cleanup';
const defaultSettings: Settings = {
themeId: undefined,
@ -66,9 +57,6 @@ const defaultSettings: Settings = {
isNotificationSounds: true,
inviteSpamFilter: true,
hour24Clock: false,
dateFormatString: 'D MMM YYYY',
developerTools: false,
};
@ -121,6 +109,21 @@ export const getSettings = (): Settings => {
setSettings(merged);
}
// System time-format migration: drop the user-facing `hour24Clock` /
// `dateFormatString` fields — both now derived from the runtime locale via
// `Intl.DateTimeFormat` (see `utils/time.ts`). One-shot strip of the orphan
// keys from existing users' persisted JSON.
if (!merged.migrationsApplied?.[SYSTEM_TIME_FORMAT_CLEANUP_KEY]) {
const orphan = merged as unknown as Record<string, unknown>;
delete orphan.hour24Clock;
delete orphan.dateFormatString;
merged.migrationsApplied = {
...(merged.migrationsApplied ?? {}),
[SYSTEM_TIME_FORMAT_CLEANUP_KEY]: true,
};
setSettings(merged);
}
return merged;
};

View file

@ -5,12 +5,36 @@ import isYesterday from 'dayjs/plugin/isYesterday';
dayjs.extend(isToday);
dayjs.extend(isYesterday);
// Detect once at module load — the runtime locale doesn't change without a
// page reload. AM/PM systems set `hour12: true`; 24-hour systems leave it
// false/undefined. Older WebView builds may omit `hour12` entirely, so we
// fall back to the modern `hourCycle` field (h11/h12 = 12-hour, h23/h24 =
// 24-hour) before defaulting to 24-hour.
const detectSystemHour24 = (): boolean => {
try {
const opts = new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions();
if (opts.hour12 === true) return false;
if (opts.hour12 === false) return true;
const cycle = (opts as { hourCycle?: string }).hourCycle;
if (cycle === 'h11' || cycle === 'h12') return false;
if (cycle === 'h23' || cycle === 'h24') return true;
return true;
} catch {
return true;
}
};
export const SYSTEM_HOUR_24: boolean = detectSystemHour24();
// 24-hour systems get the European day-month-year layout; 12-hour systems get
// the American month-day-year layout.
const SYSTEM_DAY_MON_YEAR_FORMAT = SYSTEM_HOUR_24 ? 'DD/MM/YYYY' : 'MM/DD/YYYY';
export const today = (ts: number): boolean => dayjs(ts).isToday();
export const yesterday = (ts: number): boolean => dayjs(ts).isYesterday();
export const timeHour = (ts: number, hour24Clock: boolean): string =>
dayjs(ts).format(hour24Clock ? 'HH' : 'hh');
export const timeHour = (ts: number): string => dayjs(ts).format(SYSTEM_HOUR_24 ? 'HH' : 'hh');
export const timeMinute = (ts: number): string => dayjs(ts).format('mm');
export const timeAmPm = (ts: number): string => dayjs(ts).format('A');
export const timeDay = (ts: number): string => dayjs(ts).format('D');
@ -18,11 +42,10 @@ export const timeMon = (ts: number): string => dayjs(ts).format('MMM');
export const timeMonth = (ts: number): string => dayjs(ts).format('MMMM');
export const timeYear = (ts: number): string => dayjs(ts).format('YYYY');
export const timeHourMinute = (ts: number, hour24Clock: boolean): string =>
dayjs(ts).format(hour24Clock ? 'HH:mm' : 'hh:mm A');
export const timeHourMinute = (ts: number): string =>
dayjs(ts).format(SYSTEM_HOUR_24 ? 'HH:mm' : 'hh:mm A');
export const timeDayMonYear = (ts: number, dateFormatString: string): string =>
dayjs(ts).format(dateFormatString);
export const timeDayMonYear = (ts: number): string => dayjs(ts).format(SYSTEM_DAY_MON_YEAR_FORMAT);
export const timeDayMonthYear = (ts: number): string => dayjs(ts).format('D MMMM YYYY');