mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
Date / time format
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"checkJs": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "build"]
|
||||
}
|
||||
@@ -361,7 +361,7 @@
|
||||
"account_upgrade_dialog_tier_price_per_month": "شهر",
|
||||
"prefs_appearance_theme_title": "السمة",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "لن يتم استلام الاشعارات من الخوادم الخارجية عندما يكون تطبيق الويب مغلقاً",
|
||||
"prefs_appearance_theme_system": "النظام (الافتراضي)",
|
||||
"prefs_system_default": "النظام الافتراضي",
|
||||
"prefs_notifications_min_priority_low_and_higher": "أولوية منخفضة وأعلى",
|
||||
"prefs_notifications_min_priority_default_and_higher": "الأولوية الافتراضية وما فوقها",
|
||||
"prefs_notifications_min_priority_high_and_higher": "أولوية عالية وأعلى"
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"prefs_notifications_web_push_title": "Известия във фонов режим",
|
||||
"prefs_notifications_web_push_enabled_description": "Известията ще бъдат получавани даже и ако приложението за уеб не работи (чрез Web Push)",
|
||||
"prefs_appearance_theme_title": "Цветова тема",
|
||||
"prefs_appearance_theme_system": "Системна (подразбирана)",
|
||||
"prefs_system_default": "Системна подразбирана",
|
||||
"web_push_subscription_expiring_title": "Известията временно ще бъдат спрени",
|
||||
"web_push_subscription_expiring_body": "За да продължите да получавате известия, отворете ntfy",
|
||||
"action_bar_unmute_notifications": "Включване звука на известията",
|
||||
|
||||
@@ -398,7 +398,7 @@
|
||||
"prefs_notifications_web_push_disabled_description": "Oznámení jsou přijímána, když je webová aplikace spuštěna (přes WebSocket)",
|
||||
"prefs_notifications_web_push_disabled": "Zakázáno",
|
||||
"prefs_appearance_theme_title": "Motiv",
|
||||
"prefs_appearance_theme_system": "Systém (výchozí)",
|
||||
"prefs_system_default": "Systém výchozí",
|
||||
"prefs_appearance_theme_dark": "Tmavý režim",
|
||||
"prefs_appearance_theme_light": "Světlý režim",
|
||||
"web_push_subscription_expiring_title": "Oznámení budou pozastavena",
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
"prefs_notifications_web_push_enabled": "Aktiviert für {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Deaktiviert",
|
||||
"prefs_appearance_theme_title": "Thema",
|
||||
"prefs_appearance_theme_system": "System (Standard)",
|
||||
"prefs_system_default": "System Standard",
|
||||
"prefs_appearance_theme_dark": "Nachtmodus",
|
||||
"prefs_appearance_theme_light": "Tagmodus",
|
||||
"error_boundary_button_reload_ntfy": "ntfy neu laden",
|
||||
|
||||
@@ -412,9 +412,17 @@
|
||||
"prefs_appearance_title": "Appearance",
|
||||
"prefs_appearance_language_title": "Language",
|
||||
"prefs_appearance_theme_title": "Theme",
|
||||
"prefs_appearance_theme_system": "System (default)",
|
||||
"prefs_system_default": "System default",
|
||||
"prefs_appearance_theme_dark": "Dark mode",
|
||||
"prefs_appearance_theme_light": "Light mode",
|
||||
"prefs_appearance_date_format_title": "Date format",
|
||||
"prefs_appearance_date_format_iso8601": "ISO 8601",
|
||||
"prefs_appearance_date_format_dmy": "Day/Month/Year",
|
||||
"prefs_appearance_date_format_dmy_dot": "Day.Month.Year",
|
||||
"prefs_appearance_date_format_mdy": "Month/Day/Year",
|
||||
"prefs_appearance_time_format_title": "Time format",
|
||||
"prefs_appearance_time_format_12h": "12-hour",
|
||||
"prefs_appearance_time_format_24h": "24-hour",
|
||||
"prefs_reservations_title": "Reserved topics",
|
||||
"prefs_reservations_description": "You can reserve topic names for personal use here. Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
|
||||
"prefs_reservations_limit_reached": "You reached your reserved topics limit.",
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
"prefs_notifications_web_push_enabled_description": "Las notificaciones se reciben incluso cuando la aplicación web no se está ejecutando (a través de Web Push)",
|
||||
"prefs_notifications_web_push_disabled": "Desactivado",
|
||||
"prefs_appearance_theme_title": "Tema",
|
||||
"prefs_appearance_theme_system": "Sistema (por defecto)",
|
||||
"prefs_system_default": "Sistema por defecto",
|
||||
"error_boundary_button_reload_ntfy": "Volver a cargar ntfy",
|
||||
"web_push_subscription_expiring_title": "Las notificaciones se pausarán",
|
||||
"prefs_notifications_web_push_disabled_description": "Las notificaciones se reciben cuando la aplicación web se está ejecutando (a través de WebSocket)",
|
||||
|
||||
@@ -238,7 +238,7 @@
|
||||
"account_basics_tier_title": "Kasutajakonto tüüp",
|
||||
"account_basics_tier_description": "Sinu kasutajakonto õigused",
|
||||
"account_delete_dialog_button_submit": "Kustuta kasutajakonto jäädavalt",
|
||||
"prefs_appearance_theme_system": "Süsteemi kujundus",
|
||||
"prefs_system_default": "Süsteemi kujundus",
|
||||
"prefs_appearance_theme_dark": "Tume kujundus",
|
||||
"prefs_appearance_theme_light": "Hele kujundus",
|
||||
"prefs_reservations_title": "Reserveeritud teemad",
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
"alert_notification_ios_install_required_title": "iOS-asennus vaaditaan",
|
||||
"publish_dialog_checkbox_markdown": "Muotoile Markdownina",
|
||||
"prefs_notifications_web_push_title": "Taustailmoitukset",
|
||||
"prefs_appearance_theme_system": "Järjestelmä (oletus)",
|
||||
"prefs_system_default": "Järjestelmä oletus",
|
||||
"alert_notification_permission_denied_description": "Ota ilmoitukset uudelleen käyttöön selaimessa",
|
||||
"prefs_appearance_theme_title": "Teema",
|
||||
"prefs_appearance_theme_light": "Vaalea tila",
|
||||
|
||||
@@ -396,7 +396,7 @@
|
||||
"prefs_notifications_web_push_enabled": "Activé pour {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Désactivé",
|
||||
"prefs_appearance_theme_title": "Thème",
|
||||
"prefs_appearance_theme_system": "Système (défaut)",
|
||||
"prefs_system_default": "Système défaut",
|
||||
"prefs_appearance_theme_dark": "Mode sombre",
|
||||
"prefs_appearance_theme_light": "Mode clair",
|
||||
"error_boundary_button_reload_ntfy": "Recharger ntfy",
|
||||
|
||||
@@ -398,7 +398,7 @@
|
||||
"prefs_notifications_web_push_enabled": "Activadas para {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Desactivadas",
|
||||
"prefs_appearance_theme_title": "Decorado",
|
||||
"prefs_appearance_theme_system": "Sistema (por defecto)",
|
||||
"prefs_system_default": "Sistema por defecto",
|
||||
"prefs_appearance_theme_dark": "Modo escuro",
|
||||
"prefs_appearance_theme_light": "Modo claro",
|
||||
"error_boundary_button_reload_ntfy": "Recargar ntfy",
|
||||
|
||||
@@ -371,7 +371,7 @@
|
||||
"prefs_users_description_no_sync": "A felhasználónevek és jelszavak nem kerülnek szinkronizálásra a fiókjával.",
|
||||
"prefs_users_table_cannot_delete_or_edit": "A bejelentkezett felhasználót nem lehet törölni vagy szerkeszteni",
|
||||
"prefs_appearance_theme_title": "Téma",
|
||||
"prefs_appearance_theme_system": "Rendszer (alapértelmezett)",
|
||||
"prefs_system_default": "Rendszer alapértelmezett",
|
||||
"prefs_appearance_theme_dark": "Sötét mód",
|
||||
"prefs_appearance_theme_light": "Világos mód",
|
||||
"prefs_reservations_title": "Fenntartott témák",
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
"prefs_notifications_web_push_enabled": "Diaktifkan untuk {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Dinonaktifkan",
|
||||
"prefs_appearance_theme_dark": "Mode gelap",
|
||||
"prefs_appearance_theme_system": "Sistem (bawaan)",
|
||||
"prefs_system_default": "Sistem bawaan",
|
||||
"prefs_appearance_theme_light": "Mode terang",
|
||||
"web_push_subscription_expiring_title": "Notifikasi akan dijeda",
|
||||
"web_push_subscription_expiring_body": "Buka ntfy untuk terus menerima notifikasi",
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
"prefs_notifications_web_push_disabled": "Disabilitato",
|
||||
"prefs_users_table_cannot_delete_or_edit": "Impossibile eliminare o modificare l'utente registrato",
|
||||
"prefs_appearance_theme_title": "Tema",
|
||||
"prefs_appearance_theme_system": "Sistema (predefinito)",
|
||||
"prefs_system_default": "Sistema predefinito",
|
||||
"prefs_appearance_theme_dark": "Modalità scura",
|
||||
"prefs_appearance_theme_light": "Modalità chiara",
|
||||
"prefs_reservations_table_topic_header": "Argomento",
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"prefs_notifications_web_push_enabled": "{{server}}で有効",
|
||||
"prefs_notifications_web_push_disabled": "無効",
|
||||
"prefs_appearance_theme_title": "テーマ",
|
||||
"prefs_appearance_theme_system": "システム (既定)",
|
||||
"prefs_system_default": "システム 既定",
|
||||
"prefs_appearance_theme_dark": "ダークモード",
|
||||
"web_push_unknown_notification_title": "不明な通知を受信しました",
|
||||
"web_push_unknown_notification_body": "ウェブアプリを開いてntfyをアップデートする必要があります",
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Avslutt abonnement",
|
||||
"account_tokens_description": "Bruk tilgangstokener når du publiserer og abonnerer via ntfy-APIet, slik at du ikke trenger å sende innloggingsinformasjon for kontoen din. Se <Link>dokumentasjonen</Link> for å lære mer.",
|
||||
"account_tokens_table_current_session": "Nåværende nettleserøkt",
|
||||
"prefs_appearance_theme_system": "System (standard)",
|
||||
"prefs_system_default": "System standard",
|
||||
"prefs_notifications_web_push_disabled_description": "Varslinger mottas når web-appen kjører (via WebSocket)",
|
||||
"prefs_appearance_theme_title": "Tema",
|
||||
"prefs_appearance_theme_dark": "Mørk modus",
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
"prefs_notifications_web_push_disabled": "Uitgezet",
|
||||
"prefs_notifications_web_push_enabled_description": "Notificaties worden ontvangen, ook als de webapplicatie niet geopend is (via Web Push)",
|
||||
"prefs_appearance_theme_title": "Thema",
|
||||
"prefs_appearance_theme_system": "Systeem (standaard)",
|
||||
"prefs_system_default": "Systeem standaard",
|
||||
"prefs_appearance_theme_dark": "Donkere modus",
|
||||
"prefs_appearance_theme_light": "Lichte modus",
|
||||
"error_boundary_button_reload_ntfy": "Herlaad ntfy",
|
||||
|
||||
@@ -377,7 +377,7 @@
|
||||
"prefs_notifications_web_push_disabled_description": "Powiadomienia są dostarczane kiedy aplikacja jest aktywna (poprzez WebSocket)",
|
||||
"prefs_notifications_web_push_enabled": "Włączone dla {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Wyłączone",
|
||||
"prefs_appearance_theme_system": "Systemowy (domyślny)",
|
||||
"prefs_system_default": "Systemowy domyślny",
|
||||
"prefs_appearance_theme_dark": "Tryb ciemny",
|
||||
"prefs_appearance_theme_light": "Tryb jasny",
|
||||
"prefs_reservations_edit_button": "Modyfikuj ustawienia dostępu dla tematu",
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
"prefs_notifications_web_push_disabled": "Desativado",
|
||||
"prefs_users_table_cannot_delete_or_edit": "Não é possível apagar ou editar o utilizador conectado",
|
||||
"prefs_appearance_theme_title": "Tema",
|
||||
"prefs_appearance_theme_system": "Sistema (padrão)",
|
||||
"prefs_system_default": "Sistema padrão",
|
||||
"prefs_appearance_theme_dark": "Modo escuro",
|
||||
"prefs_appearance_theme_light": "Modo claro",
|
||||
"prefs_reservations_title": "Tópicos reservados",
|
||||
|
||||
@@ -384,7 +384,7 @@
|
||||
"prefs_notifications_web_push_disabled": "Desativado",
|
||||
"prefs_appearance_theme_title": "Tema",
|
||||
"prefs_users_table_cannot_delete_or_edit": "Não é possível apagar ou editar o usuário conectado",
|
||||
"prefs_appearance_theme_system": "Sistema (padrão)",
|
||||
"prefs_system_default": "Sistema padrão",
|
||||
"prefs_appearance_theme_dark": "Modo escuro",
|
||||
"prefs_appearance_theme_light": "Modo claro",
|
||||
"prefs_reservations_title": "Tópicos reservados",
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
"prefs_appearance_title": "Aspect",
|
||||
"prefs_appearance_language_title": "Limbă",
|
||||
"prefs_appearance_theme_title": "Temă",
|
||||
"prefs_appearance_theme_system": "Sistem (implicit)",
|
||||
"prefs_system_default": "Sistem implicit",
|
||||
"prefs_appearance_theme_dark": "Mod întunecat",
|
||||
"prefs_appearance_theme_light": "Mod luminos",
|
||||
"prefs_reservations_title": "Subiecte rezervate",
|
||||
|
||||
@@ -401,7 +401,7 @@
|
||||
"notifications_actions_failed_notification": "Неудачное действие",
|
||||
"publish_dialog_checkbox_markdown": "Форматировать как Markdown",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "Уведомления с других серверов не будут получены, когда веб-приложение не открыто",
|
||||
"prefs_appearance_theme_system": "Как в системе (по умолчанию)",
|
||||
"prefs_system_default": "Как в системе по умолчанию",
|
||||
"prefs_appearance_theme_dark": "Тёмная",
|
||||
"prefs_appearance_theme_light": "Светлая",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Пользователя, созданного автоматически, нельзя изменить или удалить",
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
"prefs_notifications_web_push_enabled": "Povolené pre {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Zakázané",
|
||||
"prefs_appearance_theme_title": "Téma",
|
||||
"prefs_appearance_theme_system": "Systémové (predvolené)",
|
||||
"prefs_system_default": "Systémové predvolené",
|
||||
"prefs_appearance_theme_dark": "Tmavý režim",
|
||||
"prefs_appearance_theme_light": "Svetlý režim",
|
||||
"error_boundary_button_reload_ntfy": "Obnoviť ntfy",
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
"prefs_notifications_web_push_enabled": "Aktivera för {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Avaktivera",
|
||||
"prefs_appearance_theme_title": "Tema",
|
||||
"prefs_appearance_theme_system": "System (basinställning)",
|
||||
"prefs_system_default": "System basinställning",
|
||||
"prefs_appearance_theme_dark": "Mörkt läge",
|
||||
"prefs_appearance_theme_light": "Ljust läge",
|
||||
"error_boundary_button_reload_ntfy": "Ladda om ntfy",
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
"prefs_appearance_title": "தோற்றம்",
|
||||
"prefs_appearance_language_title": "மொழி",
|
||||
"prefs_appearance_theme_title": "கருப்பொருள்",
|
||||
"prefs_appearance_theme_system": "கணினி (இயல்புநிலை)",
|
||||
"prefs_system_default": "கணினி இயல்புநிலை",
|
||||
"prefs_appearance_theme_dark": "இருண்ட முறை",
|
||||
"prefs_appearance_theme_light": "ஒளி பயன்முறை",
|
||||
"prefs_reservations_title": "ஒதுக்கப்பட்ட தலைப்புகள்",
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
"prefs_notifications_web_push_enabled": "{{server}} için etkinleştirildi",
|
||||
"prefs_notifications_web_push_disabled": "Devre dışı",
|
||||
"prefs_appearance_theme_title": "Tema",
|
||||
"prefs_appearance_theme_system": "Sistem (öntanımlı)",
|
||||
"prefs_system_default": "Sistem öntanımlı",
|
||||
"prefs_appearance_theme_dark": "Koyu mod",
|
||||
"prefs_appearance_theme_light": "Açık mod",
|
||||
"error_boundary_button_reload_ntfy": "ntfy'yi yeniden yükle",
|
||||
|
||||
@@ -398,7 +398,7 @@
|
||||
"prefs_notifications_web_push_enabled": "Увімкнено для {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Вимкнено",
|
||||
"prefs_appearance_theme_title": "Тема оформлення",
|
||||
"prefs_appearance_theme_system": "Система (за замовчуванням)",
|
||||
"prefs_system_default": "Система за замовчуванням",
|
||||
"prefs_appearance_theme_light": "Світлий режим",
|
||||
"error_boundary_button_reload_ntfy": "Перезавантажити ntfy",
|
||||
"web_push_subscription_expiring_title": "Сповіщення буде призупинено",
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"prefs_appearance_title": "外观",
|
||||
"prefs_appearance_language_title": "语言",
|
||||
"prefs_appearance_theme_title": "主題",
|
||||
"prefs_appearance_theme_system": "系统 (默认)",
|
||||
"prefs_system_default": "系统 默认",
|
||||
"prefs_appearance_theme_dark": "深色模式",
|
||||
"prefs_appearance_theme_light": "浅色模式",
|
||||
"priority_min": "最低",
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
"prefs_appearance_language_title": "語言",
|
||||
"prefs_appearance_theme_dark": "深色模式",
|
||||
"prefs_appearance_theme_light": "淺色模式",
|
||||
"prefs_appearance_theme_system": "系統 (預設)",
|
||||
"prefs_system_default": "系統 預設",
|
||||
"prefs_appearance_theme_title": "主題",
|
||||
"prefs_appearance_title": "外觀",
|
||||
"prefs_notifications_delete_after_never_description": "永不自動刪除通知",
|
||||
|
||||
@@ -448,6 +448,12 @@ class AccountApi {
|
||||
if (account.language) {
|
||||
await i18n.changeLanguage(account.language);
|
||||
}
|
||||
if (account.date_format) {
|
||||
await prefs.setDateFormat(account.date_format);
|
||||
}
|
||||
if (account.time_format) {
|
||||
await prefs.setTimeFormat(account.time_format);
|
||||
}
|
||||
if (account.notification) {
|
||||
if (account.notification.sound) {
|
||||
await prefs.setSound(account.notification.sound);
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock("./Session", () => ({
|
||||
}));
|
||||
vi.mock("./SubscriptionManager", () => ({ default: { syncFromRemote: vi.fn() } }));
|
||||
vi.mock("./Prefs", () => ({
|
||||
default: { setSound: vi.fn(), setDeleteAfter: vi.fn(), setMinPriority: vi.fn() },
|
||||
default: { setSound: vi.fn(), setDeleteAfter: vi.fn(), setMinPriority: vi.fn(), setDateFormat: vi.fn(), setTimeFormat: vi.fn() },
|
||||
THEME: { DARK: "dark", LIGHT: "light", SYSTEM: "system" },
|
||||
}));
|
||||
vi.mock("i18next", () => ({ default: { changeLanguage: vi.fn() } }));
|
||||
@@ -190,6 +190,8 @@ describe("AccountApi.sync", () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
ok({
|
||||
language: "de",
|
||||
date_format: "iso8601",
|
||||
time_format: "24h",
|
||||
notification: { sound: "ding", delete_after: 3600, min_priority: 3 },
|
||||
subscriptions: [{ topic: "t" }],
|
||||
reservations: [{ topic: "t" }],
|
||||
@@ -199,6 +201,8 @@ describe("AccountApi.sync", () => {
|
||||
await accountApi.sync();
|
||||
|
||||
expect(i18n.changeLanguage).toHaveBeenCalledWith("de");
|
||||
expect(prefs.setDateFormat).toHaveBeenCalledWith("iso8601");
|
||||
expect(prefs.setTimeFormat).toHaveBeenCalledWith("24h");
|
||||
expect(prefs.setSound).toHaveBeenCalledWith("ding");
|
||||
expect(prefs.setDeleteAfter).toHaveBeenCalledWith(3600);
|
||||
expect(prefs.setMinPriority).toHaveBeenCalledWith(3);
|
||||
|
||||
+46
-1
@@ -6,16 +6,43 @@ export const THEME = {
|
||||
SYSTEM: "system",
|
||||
};
|
||||
|
||||
// How the date component is displayed. SYSTEM follows the browser's locale (i.e. the OS region/
|
||||
// format settings, not the chosen UI translation language); ISO8601 forces YYYY-MM-DD; DMY and MDY
|
||||
// force the day/month order regardless of locale (e.g. for an English UI that defaults to US order).
|
||||
export const DATE_FORMAT = {
|
||||
SYSTEM: "system",
|
||||
ISO8601: "iso8601",
|
||||
DMY: "dmy",
|
||||
DMY_DOT: "dmy_dot",
|
||||
MDY: "mdy",
|
||||
};
|
||||
|
||||
// How the time component (clock) is displayed. Orthogonal to DATE_FORMAT and to the UI language --
|
||||
// SYSTEM lets the browser locale decide, H12/H24 force a 12- or 24-hour clock. ISO 8601 dates are
|
||||
// always 24-hour, so this setting is ignored in that mode.
|
||||
export const TIME_FORMAT = {
|
||||
SYSTEM: "system",
|
||||
H12: "12h",
|
||||
H24: "24h",
|
||||
};
|
||||
|
||||
// Default values the getters return when a pref is unset; also used by PrefCache (PrefCache.jsx).
|
||||
export const PREF_DEFAULTS = {
|
||||
sound: "ding",
|
||||
minPriority: 1,
|
||||
deleteAfter: 604800, // one week
|
||||
theme: THEME.SYSTEM,
|
||||
dateFormat: DATE_FORMAT.SYSTEM,
|
||||
timeFormat: TIME_FORMAT.SYSTEM,
|
||||
webPushEnabled: false,
|
||||
};
|
||||
|
||||
class Prefs {
|
||||
// Guards against values the current build doesn't know about (e.g. a pref synced from a newer
|
||||
// client, or a renamed enum). An unknown value would leave the Settings <Select> with no matching
|
||||
// option, so we fall back to the default instead.
|
||||
const isKnownValue = (constants, value) => Object.values(constants).includes(value);
|
||||
|
||||
export class Prefs {
|
||||
constructor(dbImpl) {
|
||||
this.db = dbImpl;
|
||||
}
|
||||
@@ -64,6 +91,24 @@ class Prefs {
|
||||
async setTheme(mode) {
|
||||
await this.db.prefs.put({ key: "theme", value: mode });
|
||||
}
|
||||
|
||||
async dateFormat() {
|
||||
const dateFormat = await this.db.prefs.get("dateFormat");
|
||||
return isKnownValue(DATE_FORMAT, dateFormat?.value) ? dateFormat.value : PREF_DEFAULTS.dateFormat;
|
||||
}
|
||||
|
||||
async setDateFormat(format) {
|
||||
await this.db.prefs.put({ key: "dateFormat", value: format });
|
||||
}
|
||||
|
||||
async timeFormat() {
|
||||
const timeFormat = await this.db.prefs.get("timeFormat");
|
||||
return isKnownValue(TIME_FORMAT, timeFormat?.value) ? timeFormat.value : PREF_DEFAULTS.timeFormat;
|
||||
}
|
||||
|
||||
async setTimeFormat(format) {
|
||||
await this.db.prefs.put({ key: "timeFormat", value: format });
|
||||
}
|
||||
}
|
||||
|
||||
const prefs = new Prefs(db());
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Prefs, DATE_FORMAT, TIME_FORMAT, PREF_DEFAULTS } from "./Prefs";
|
||||
|
||||
// The module instantiates `new Prefs(db())` at import; stub db() so that doesn't touch IndexedDB.
|
||||
vi.mock("./db", () => ({ default: () => ({}) }));
|
||||
|
||||
// Minimal fake of the Dexie `prefs` table: get() returns a stored {key, value} or undefined.
|
||||
const fakeDb = (stored) => ({
|
||||
prefs: {
|
||||
get: async (key) => (key in stored ? { key, value: stored[key] } : undefined),
|
||||
put: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
describe("Prefs date/time format validation", () => {
|
||||
it("returns the stored value when it is a known format", async () => {
|
||||
const prefs = new Prefs(fakeDb({ dateFormat: DATE_FORMAT.ISO8601, timeFormat: TIME_FORMAT.H24 }));
|
||||
expect(await prefs.dateFormat()).toBe(DATE_FORMAT.ISO8601);
|
||||
expect(await prefs.timeFormat()).toBe(TIME_FORMAT.H24);
|
||||
});
|
||||
|
||||
it("falls back to the default for an unknown stored value (e.g. synced from a newer client)", async () => {
|
||||
const prefs = new Prefs(fakeDb({ dateFormat: "newer-client-format", timeFormat: "36h" }));
|
||||
expect(await prefs.dateFormat()).toBe(PREF_DEFAULTS.dateFormat);
|
||||
expect(await prefs.timeFormat()).toBe(PREF_DEFAULTS.timeFormat);
|
||||
});
|
||||
|
||||
it("falls back to the default when unset", async () => {
|
||||
const prefs = new Prefs(fakeDb({}));
|
||||
expect(await prefs.dateFormat()).toBe(PREF_DEFAULTS.dateFormat);
|
||||
expect(await prefs.timeFormat()).toBe(PREF_DEFAULTS.timeFormat);
|
||||
});
|
||||
});
|
||||
+59
-6
@@ -7,7 +7,7 @@ import pop from "../sounds/pop.mp3";
|
||||
import popSwoosh from "../sounds/pop-swoosh.mp3";
|
||||
import config from "./config";
|
||||
import emojisMapped from "./emojisMapped";
|
||||
import { THEME } from "./Prefs";
|
||||
import { THEME, DATE_FORMAT, TIME_FORMAT } from "./Prefs";
|
||||
|
||||
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
|
||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||
@@ -151,14 +151,67 @@ export const hashCode = (s) => {
|
||||
*/
|
||||
export const getKebabCaseLangStr = (language) => (typeof language === "string" && language.length > 0 ? language.replace(/_/g, "-") : "en");
|
||||
|
||||
export const formatShortDateTime = (timestamp, language) =>
|
||||
new Intl.DateTimeFormat(getKebabCaseLangStr(language), {
|
||||
dateStyle: "short",
|
||||
// Date/time display is intentionally NOT tied to the UI translation language; it follows the
|
||||
// dedicated dateFormat/timeFormat prefs (see Prefs.js). SYSTEM modes pass no locale to Intl so it
|
||||
// uses the browser's default (the user's OS region/format settings). DMY/MDY force the day/month
|
||||
// order; ISO8601 forces an unambiguous, locale-independent and always-24-hour format. The clock
|
||||
// (12h/24h) is chosen independently via timeFormat. All variants render local time.
|
||||
const pad2 = (n) => String(n).padStart(2, "0");
|
||||
|
||||
// Per-format date builders (only ISO zero-pads; the rest drop leading zeros). The router below
|
||||
// (formatDate) picks one of these for a fixed-order format, or falls back to the browser locale.
|
||||
const formatDateIso = (date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
const formatDateDmy = (date) => `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`;
|
||||
const formatDateDmyDot = (date) => `${date.getDate()}.${date.getMonth() + 1}.${date.getFullYear()}`;
|
||||
const formatDateMdy = (date) => `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
|
||||
|
||||
// ISO date-time is always YYYY-MM-DD HH:mm (24-hour), independent of the timeFormat pref.
|
||||
const formatDateTimeIso = (date) => `${formatDateIso(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
||||
|
||||
// undefined lets the locale decide the clock; true/false force a 12- or 24-hour clock. Passing
|
||||
// hour12: undefined to Intl is equivalent to omitting it (and avoids an object spread that would
|
||||
// otherwise widen the sibling dateStyle/timeStyle string literals).
|
||||
const hour12For = (timeFormat) => {
|
||||
if (timeFormat === TIME_FORMAT.H12) return true;
|
||||
if (timeFormat === TIME_FORMAT.H24) return false;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// The date router: delegates to a fixed-order builder, or falls back to the browser locale (SYSTEM).
|
||||
export const formatDate = (timestamp, dateFormat = DATE_FORMAT.SYSTEM) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
switch (dateFormat) {
|
||||
case DATE_FORMAT.ISO8601:
|
||||
return formatDateIso(date);
|
||||
case DATE_FORMAT.DMY:
|
||||
return formatDateDmy(date);
|
||||
case DATE_FORMAT.DMY_DOT:
|
||||
return formatDateDmyDot(date);
|
||||
case DATE_FORMAT.MDY:
|
||||
return formatDateMdy(date);
|
||||
default:
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "short" }).format(date);
|
||||
}
|
||||
};
|
||||
|
||||
export const formatTime = (timestamp, timeFormat = TIME_FORMAT.SYSTEM) =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: "short",
|
||||
hour12: hour12For(timeFormat),
|
||||
}).format(new Date(timestamp * 1000));
|
||||
|
||||
export const formatShortDate = (timestamp, language) =>
|
||||
new Intl.DateTimeFormat(getKebabCaseLangStr(language), { dateStyle: "short" }).format(new Date(timestamp * 1000));
|
||||
export const formatDateTime = (timestamp, dateFormat = DATE_FORMAT.SYSTEM, timeFormat = TIME_FORMAT.SYSTEM) => {
|
||||
if (dateFormat === DATE_FORMAT.ISO8601) {
|
||||
return formatDateTimeIso(new Date(timestamp * 1000));
|
||||
} else if ([DATE_FORMAT.DMY, DATE_FORMAT.DMY_DOT, DATE_FORMAT.MDY].includes(dateFormat)) {
|
||||
return `${formatDate(timestamp, dateFormat)} ${formatTime(timestamp, timeFormat)}`;
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
hour12: hour12For(timeFormat),
|
||||
}).format(new Date(timestamp * 1000));
|
||||
};
|
||||
|
||||
export const formatShortDuration = (ms, language) => {
|
||||
const seconds = Math.round(ms / 1000);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { THEME } from "./Prefs";
|
||||
import { THEME, DATE_FORMAT, TIME_FORMAT } from "./Prefs";
|
||||
import {
|
||||
topicUrl,
|
||||
topicUrlWs,
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
formatNumber,
|
||||
formatPrice,
|
||||
formatShortDuration,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatTime,
|
||||
getKebabCaseLangStr,
|
||||
darkModeEnabled,
|
||||
urlB64ToUint8Array,
|
||||
@@ -151,3 +154,65 @@ describe("misc pure helpers", () => {
|
||||
expect(Array.from(urlB64ToUint8Array("AQID"))).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("date/time formatting", () => {
|
||||
// 2026-03-08 14:30 in the runner's local timezone (the same wall-clock time Intl renders).
|
||||
const ts = Math.floor(new Date(2026, 2, 8, 14, 30, 0).getTime() / 1000);
|
||||
|
||||
it("ISO 8601 mode renders YYYY-MM-DD HH:mm regardless of locale", () => {
|
||||
expect(formatDateTime(ts, DATE_FORMAT.ISO8601)).toBe("2026-03-08 14:30");
|
||||
expect(formatDate(ts, DATE_FORMAT.ISO8601)).toBe("2026-03-08");
|
||||
});
|
||||
|
||||
it("ISO 8601 mode zero-pads single-digit months, days, hours and minutes", () => {
|
||||
const early = Math.floor(new Date(2026, 0, 5, 9, 7, 0).getTime() / 1000);
|
||||
expect(formatDateTime(early, DATE_FORMAT.ISO8601)).toBe("2026-01-05 09:07");
|
||||
});
|
||||
|
||||
it("default mode follows the browser locale, not the UI translation language", () => {
|
||||
// The format must depend only on the browser's default locale -- passing a language
|
||||
// tag (the old behavior) must NOT change the output anymore.
|
||||
const browserDefault = new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" }).format(new Date(ts * 1000));
|
||||
expect(formatDateTime(ts, DATE_FORMAT.SYSTEM)).toBe(browserDefault);
|
||||
expect(formatDateTime(ts, "fr")).toBe(browserDefault);
|
||||
expect(formatDateTime(ts, "en_US")).toBe(browserDefault);
|
||||
expect(formatDateTime(ts, undefined)).toBe(browserDefault);
|
||||
});
|
||||
|
||||
it("only ISO 8601 zero-pads; DMY, dot-separated DMY and MDY all drop leading zeros", () => {
|
||||
expect(formatDate(ts, DATE_FORMAT.DMY)).toBe("8/3/2026");
|
||||
expect(formatDate(ts, DATE_FORMAT.DMY_DOT)).toBe("8.3.2026");
|
||||
expect(formatDate(ts, DATE_FORMAT.MDY)).toBe("3/8/2026");
|
||||
// Double-digit day/month are left intact.
|
||||
const ts2 = Math.floor(new Date(2026, 11, 25, 14, 30, 0).getTime() / 1000);
|
||||
expect(formatDate(ts2, DATE_FORMAT.DMY)).toBe("25/12/2026");
|
||||
expect(formatDate(ts2, DATE_FORMAT.DMY_DOT)).toBe("25.12.2026");
|
||||
expect(formatDate(ts2, DATE_FORMAT.MDY)).toBe("12/25/2026");
|
||||
// The same ordering prefixes the datetime variant.
|
||||
expect(formatDateTime(ts, DATE_FORMAT.DMY)).toMatch(/^8\/3\/2026 /);
|
||||
expect(formatDateTime(ts, DATE_FORMAT.DMY_DOT)).toMatch(/^8\.3\.2026 /);
|
||||
expect(formatDateTime(ts, DATE_FORMAT.MDY)).toMatch(/^3\/8\/2026 /);
|
||||
});
|
||||
|
||||
it("formatTime renders only the time, honoring the 12/24-hour choice", () => {
|
||||
const local24 = new Intl.DateTimeFormat(undefined, { timeStyle: "short", hour12: false }).format(new Date(ts * 1000));
|
||||
expect(formatTime(ts, TIME_FORMAT.H24)).toBe(local24);
|
||||
expect(formatTime(ts, TIME_FORMAT.H24)).toContain("14:30");
|
||||
expect(formatTime(ts, TIME_FORMAT.H12)).toContain("2:30");
|
||||
});
|
||||
|
||||
it("time format selects 12-hour vs 24-hour independently of the date format", () => {
|
||||
const local24 = new Intl.DateTimeFormat(undefined, { timeStyle: "short", hour12: false }).format(new Date(ts * 1000));
|
||||
expect(formatDateTime(ts, DATE_FORMAT.DMY, TIME_FORMAT.H24)).toBe(`8/3/2026 ${local24}`);
|
||||
|
||||
const h12 = formatDateTime(ts, DATE_FORMAT.SYSTEM, TIME_FORMAT.H12);
|
||||
const h24 = formatDateTime(ts, DATE_FORMAT.SYSTEM, TIME_FORMAT.H24);
|
||||
expect(h24).toContain("14:30");
|
||||
expect(h12).toContain("2:30");
|
||||
expect(h12).not.toBe(h24);
|
||||
});
|
||||
|
||||
it("ISO 8601 is always 24-hour regardless of the time format", () => {
|
||||
expect(formatDateTime(ts, DATE_FORMAT.ISO8601, TIME_FORMAT.H12)).toBe("2026-03-08 14:30");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,12 +52,13 @@ import ContentCopy from "@mui/icons-material/ContentCopy";
|
||||
import Public from "@mui/icons-material/Public";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import routes from "./routes";
|
||||
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, formatShortDuration, openUrl } from "../app/utils";
|
||||
import { copyToClipboard, formatBytes, formatDate, formatDateTime, formatShortDuration, openUrl } from "../app/utils";
|
||||
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
|
||||
import { Pref, PrefGroup } from "./Pref";
|
||||
import db from "../app/db";
|
||||
import UpgradeDialog from "./UpgradeDialog";
|
||||
import AccountContext from "./AccountContext";
|
||||
import { usePrefCache } from "./PrefCache";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import { Paragraph } from "./styles";
|
||||
import { EmailPrimaryElsewhereError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
||||
@@ -242,7 +243,8 @@ const ChangePasswordDialog = (props) => {
|
||||
};
|
||||
|
||||
const AccountType = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { dateFormat } = usePrefCache();
|
||||
const { account } = useContext(AccountContext);
|
||||
const [upgradeDialogKey, setUpgradeDialogKey] = useState(0);
|
||||
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
||||
@@ -304,7 +306,7 @@ const AccountType = () => {
|
||||
{account.billing?.paid_until && !account.billing?.cancel_at && (
|
||||
<Tooltip
|
||||
title={t("account_basics_tier_paid_until", {
|
||||
date: formatShortDate(account.billing?.paid_until, i18n.language),
|
||||
date: formatDate(account.billing?.paid_until, dateFormat),
|
||||
})}
|
||||
>
|
||||
<span>
|
||||
@@ -349,7 +351,7 @@ const AccountType = () => {
|
||||
{account.billing?.cancel_at > 0 && (
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
{t("account_basics_tier_canceled_subscription", {
|
||||
date: formatShortDate(account.billing.cancel_at, i18n.language),
|
||||
date: formatDate(account.billing.cancel_at, dateFormat),
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
@@ -1076,7 +1078,8 @@ const Tokens = () => {
|
||||
};
|
||||
|
||||
const TokensTable = (props) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { dateFormat, timeFormat } = usePrefCache();
|
||||
const [snackOpen, setSnackOpen] = useState(false);
|
||||
const [upsertDialogKey, setUpsertDialogKey] = useState(0);
|
||||
const [upsertDialogOpen, setUpsertDialogOpen] = useState(false);
|
||||
@@ -1154,11 +1157,11 @@ const TokensTable = (props) => {
|
||||
{token.token !== session.token() && (token.label || "-")}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
|
||||
{token.expires ? formatShortDateTime(token.expires, i18n.language) : <em>{t("account_tokens_table_never_expires")}</em>}
|
||||
{token.expires ? formatDateTime(token.expires, dateFormat, timeFormat) : <em>{t("account_tokens_table_never_expires")}</em>}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{hasLastAccess ? <span>{formatShortDateTime(token.last_access, i18n.language)}</span> : <em>-</em>}
|
||||
{hasLastAccess ? <span>{formatDateTime(token.last_access, dateFormat, timeFormat)}</span> : <em>-</em>}
|
||||
{hasLastOrigin && (
|
||||
<Tooltip
|
||||
title={t("account_tokens_table_last_origin_tooltip", {
|
||||
|
||||
@@ -23,16 +23,7 @@ import CloseIcon from "@mui/icons-material/Close";
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import {
|
||||
copyToClipboard,
|
||||
formatBytes,
|
||||
formatShortDateTime,
|
||||
maybeActionErrors,
|
||||
openUrl,
|
||||
shortUrl,
|
||||
topicUrl,
|
||||
unmatchedTags,
|
||||
} from "../app/utils";
|
||||
import { copyToClipboard, formatBytes, formatDateTime, maybeActionErrors, openUrl, shortUrl, topicUrl, unmatchedTags } from "../app/utils";
|
||||
import { ACTION_BROADCAST, ACTION_COPY, ACTION_HTTP, ACTION_VIEW } from "../app/actions";
|
||||
import { formatMessage, formatTitle, isImage } from "../app/notificationUtils";
|
||||
import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
|
||||
@@ -45,6 +36,7 @@ import priority5 from "../img/priority-5.svg";
|
||||
import logoOutline from "../img/ntfy-outline.svg";
|
||||
import AttachmentIcon from "./AttachmentIcon";
|
||||
import { useAutoSubscribe } from "./hooks";
|
||||
import { usePrefCache } from "./PrefCache";
|
||||
|
||||
const priorityFiles = {
|
||||
1: priority1,
|
||||
@@ -186,10 +178,11 @@ const NotificationBody = ({ notification }) => {
|
||||
};
|
||||
|
||||
const NotificationItem = (props) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { dateFormat, timeFormat } = usePrefCache();
|
||||
const { notification } = props;
|
||||
const { attachment } = notification;
|
||||
const date = formatShortDateTime(notification.time, i18n.language);
|
||||
const date = formatDateTime(notification.time, dateFormat, timeFormat);
|
||||
const otherTags = unmatchedTags(notification.tags);
|
||||
const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
|
||||
const handleDelete = async () => {
|
||||
@@ -305,7 +298,8 @@ const NotificationItem = (props) => {
|
||||
};
|
||||
|
||||
const Attachment = (props) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { dateFormat, timeFormat } = usePrefCache();
|
||||
const { attachment } = props;
|
||||
const expired = attachment.expires && attachment.expires < Date.now() / 1000;
|
||||
const expires = attachment.expires && attachment.expires > Date.now() / 1000;
|
||||
@@ -324,7 +318,7 @@ const Attachment = (props) => {
|
||||
if (expires) {
|
||||
infos.push(
|
||||
t("notifications_attachment_link_expires", {
|
||||
date: formatShortDateTime(attachment.expires, i18n.language),
|
||||
date: formatDateTime(attachment.expires, dateFormat, timeFormat),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export const PrefCacheProvider = ({ children }) => {
|
||||
minPriority: await prefs.minPriority(),
|
||||
deleteAfter: await prefs.deleteAfter(),
|
||||
theme: await prefs.theme(),
|
||||
dateFormat: await prefs.dateFormat(),
|
||||
timeFormat: await prefs.timeFormat(),
|
||||
webPushEnabled: await prefs.webPushEnabled(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
CardActions,
|
||||
CardContent,
|
||||
Chip,
|
||||
@@ -36,14 +37,14 @@ import { useTranslation } from "react-i18next";
|
||||
import Info from "@mui/icons-material/Info";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import userManager from "../app/UserManager";
|
||||
import { playSound, shortUrl, shuffle, sounds, validUrl } from "../app/utils";
|
||||
import { formatDate, formatTime, playSound, shortUrl, shuffle, sounds, validUrl } from "../app/utils";
|
||||
import session from "../app/Session";
|
||||
import routes from "./routes";
|
||||
import accountApi, { Permission, Role } from "../app/AccountApi";
|
||||
import { Pref, PrefGroup } from "./Pref";
|
||||
import AccountContext from "./AccountContext";
|
||||
import { Paragraph } from "./styles";
|
||||
import prefs, { THEME } from "../app/Prefs";
|
||||
import prefs, { THEME, DATE_FORMAT, TIME_FORMAT } from "../app/Prefs";
|
||||
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
|
||||
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
|
||||
import { UnauthorizedError } from "../app/errors";
|
||||
@@ -228,10 +229,18 @@ const DeleteAfter = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Shows the resolved/sample value of an option (the current system mode, or a date/time sample) in muted gray.
|
||||
const FormatExample = ({ children }) => (
|
||||
<Box component="span" sx={{ color: "text.secondary", ml: 1 }}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const Theme = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefTheme";
|
||||
const { theme } = usePrefCache();
|
||||
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setTheme(ev.target.value);
|
||||
};
|
||||
@@ -240,7 +249,10 @@ const Theme = () => {
|
||||
<Pref labelId={labelId} title={t("prefs_appearance_theme_title")}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={theme} onChange={handleChange} aria-labelledby={labelId}>
|
||||
<MenuItem value={THEME.SYSTEM}>{t("prefs_appearance_theme_system")}</MenuItem>
|
||||
<MenuItem value={THEME.SYSTEM}>
|
||||
{t("prefs_system_default")}
|
||||
<FormatExample>{prefersDarkMode ? t("prefs_appearance_theme_dark") : t("prefs_appearance_theme_light")}</FormatExample>
|
||||
</MenuItem>
|
||||
<MenuItem value={THEME.DARK}>{t("prefs_appearance_theme_dark")}</MenuItem>
|
||||
<MenuItem value={THEME.LIGHT}>{t("prefs_appearance_theme_light")}</MenuItem>
|
||||
</Select>
|
||||
@@ -249,6 +261,84 @@ const Theme = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// An unambiguous sample (April 26, 2:30 PM, current year) used for each option's example: the day
|
||||
// (26) is too large to be a month, so the day/month order is unmistakable, and 14:30 distinguishes
|
||||
// the 12h/24h clock. Using today's date could be ambiguous (e.g. when the day could pass for a month).
|
||||
const EXAMPLE_TIMESTAMP = Math.round(new Date(new Date().getFullYear(), 3, 26, 14, 30).getTime() / 1000);
|
||||
|
||||
const DateFormat = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefDateFormat";
|
||||
const { dateFormat } = usePrefCache();
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setDateFormat(ev.target.value);
|
||||
await maybeUpdateAccountSettings({ date_format: ev.target.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Pref labelId={labelId} title={t("prefs_appearance_date_format_title")}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={dateFormat} onChange={handleChange} aria-labelledby={labelId}>
|
||||
<MenuItem value={DATE_FORMAT.SYSTEM}>
|
||||
{t("prefs_system_default")}
|
||||
<FormatExample>{formatDate(EXAMPLE_TIMESTAMP, DATE_FORMAT.SYSTEM)}</FormatExample>
|
||||
</MenuItem>
|
||||
<MenuItem value={DATE_FORMAT.ISO8601}>
|
||||
{t("prefs_appearance_date_format_iso8601")}
|
||||
<FormatExample>{formatDate(EXAMPLE_TIMESTAMP, DATE_FORMAT.ISO8601)}</FormatExample>
|
||||
</MenuItem>
|
||||
<MenuItem value={DATE_FORMAT.DMY}>
|
||||
{t("prefs_appearance_date_format_dmy")}
|
||||
<FormatExample>{formatDate(EXAMPLE_TIMESTAMP, DATE_FORMAT.DMY)}</FormatExample>
|
||||
</MenuItem>
|
||||
<MenuItem value={DATE_FORMAT.DMY_DOT}>
|
||||
{t("prefs_appearance_date_format_dmy_dot")}
|
||||
<FormatExample>{formatDate(EXAMPLE_TIMESTAMP, DATE_FORMAT.DMY_DOT)}</FormatExample>
|
||||
</MenuItem>
|
||||
<MenuItem value={DATE_FORMAT.MDY}>
|
||||
{t("prefs_appearance_date_format_mdy")}
|
||||
<FormatExample>{formatDate(EXAMPLE_TIMESTAMP, DATE_FORMAT.MDY)}</FormatExample>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Pref>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeFormat = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefTimeFormat";
|
||||
const { timeFormat, dateFormat } = usePrefCache();
|
||||
// ISO 8601 dates are always 24-hour, so the clock choice is fixed while it's selected. Disable the
|
||||
// control and show 24-hour, but leave the stored pref untouched so it returns for other date formats.
|
||||
const fixedTo24h = dateFormat === DATE_FORMAT.ISO8601;
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setTimeFormat(ev.target.value);
|
||||
await maybeUpdateAccountSettings({ time_format: ev.target.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Pref labelId={labelId} title={t("prefs_appearance_time_format_title")}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={fixedTo24h ? TIME_FORMAT.H24 : timeFormat} onChange={handleChange} disabled={fixedTo24h} aria-labelledby={labelId}>
|
||||
<MenuItem value={TIME_FORMAT.SYSTEM}>
|
||||
{t("prefs_system_default")}
|
||||
<FormatExample>{formatTime(EXAMPLE_TIMESTAMP, TIME_FORMAT.SYSTEM)}</FormatExample>
|
||||
</MenuItem>
|
||||
<MenuItem value={TIME_FORMAT.H12}>
|
||||
{t("prefs_appearance_time_format_12h")}
|
||||
<FormatExample>{formatTime(EXAMPLE_TIMESTAMP, TIME_FORMAT.H12)}</FormatExample>
|
||||
</MenuItem>
|
||||
<MenuItem value={TIME_FORMAT.H24}>
|
||||
{t("prefs_appearance_time_format_24h")}
|
||||
<FormatExample>{formatTime(EXAMPLE_TIMESTAMP, TIME_FORMAT.H24)}</FormatExample>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Pref>
|
||||
);
|
||||
};
|
||||
|
||||
const WebPushEnabled = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefWebPushEnabled";
|
||||
@@ -518,6 +608,8 @@ const Appearance = () => {
|
||||
</Typography>
|
||||
<PrefGroup>
|
||||
<Theme />
|
||||
<DateFormat />
|
||||
<TimeFormat />
|
||||
<Language />
|
||||
</PrefGroup>
|
||||
</Card>
|
||||
|
||||
@@ -35,14 +35,16 @@ import accountApi, { Role } from "../app/AccountApi";
|
||||
import session from "../app/Session";
|
||||
import routes from "./routes";
|
||||
import PopupMenu from "./PopupMenu";
|
||||
import { formatShortDateTime, shuffle } from "../app/utils";
|
||||
import { formatDateTime, shuffle } from "../app/utils";
|
||||
import api from "../app/Api";
|
||||
import AccountContext from "./AccountContext";
|
||||
import { usePrefCache } from "./PrefCache";
|
||||
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
|
||||
import { UnauthorizedError } from "../app/errors";
|
||||
|
||||
export const SubscriptionPopup = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { dateFormat, timeFormat } = usePrefCache();
|
||||
const { account } = useContext(AccountContext);
|
||||
const navigate = useNavigate();
|
||||
const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false);
|
||||
@@ -115,15 +117,17 @@ export const SubscriptionPopup = (props) => {
|
||||
])[0];
|
||||
const nowSeconds = Math.round(Date.now() / 1000);
|
||||
const message = shuffle([
|
||||
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(
|
||||
`Hello friend, this is a test notification from ntfy web. It's ${formatDateTime(
|
||||
nowSeconds,
|
||||
"en-US"
|
||||
dateFormat,
|
||||
timeFormat
|
||||
)} right now. Is that early or late?`,
|
||||
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
|
||||
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
|
||||
`Alright then, it's ${formatShortDateTime(
|
||||
`Alright then, it's ${formatDateTime(
|
||||
nowSeconds,
|
||||
"en-US"
|
||||
dateFormat,
|
||||
timeFormat
|
||||
)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
|
||||
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
|
||||
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
|
||||
|
||||
@@ -28,8 +28,9 @@ import Check from "@mui/icons-material/Check";
|
||||
import Close from "@mui/icons-material/Close";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { UnauthorizedError } from "../app/errors";
|
||||
import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils";
|
||||
import { formatBytes, formatNumber, formatPrice, formatDate } from "../app/utils";
|
||||
import AccountContext from "./AccountContext";
|
||||
import { usePrefCache } from "./PrefCache";
|
||||
import routes from "./routes";
|
||||
import session from "../app/Session";
|
||||
import accountApi, { SubscriptionInterval } from "../app/AccountApi";
|
||||
@@ -63,7 +64,8 @@ const Banner = {
|
||||
|
||||
const UpgradeDialog = (props) => {
|
||||
const theme = useTheme();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { dateFormat } = usePrefCache();
|
||||
const { account } = useContext(AccountContext); // May be undefined!
|
||||
const [error, setError] = useState("");
|
||||
const [tiers, setTiers] = useState(null);
|
||||
@@ -234,7 +236,7 @@ const UpgradeDialog = (props) => {
|
||||
<Trans
|
||||
i18nKey="account_upgrade_dialog_cancel_warning"
|
||||
values={{
|
||||
date: formatShortDate(account?.billing?.paid_until || 0, i18n.language),
|
||||
date: formatDate(account?.billing?.paid_until || 0, dateFormat),
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
|
||||
Reference in New Issue
Block a user