mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-13 23:25:22 +00:00
Date / time format
This commit is contained in:
@@ -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