Merge pull request #804 from nimbleghost/rtl

Web app: add RTL support
This commit is contained in:
Philipp C. Heckel 2023-07-04 14:20:24 -04:00 committed by GitHub
commit 495fb24b9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 121 additions and 46 deletions

40
web/package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "ntfy", "name": "ntfy",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.0", "@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.4.2", "@mui/icons-material": "^5.4.2",
@ -25,7 +26,9 @@
"react-infinite-scroll-component": "^6.1.0", "react-infinite-scroll-component": "^6.1.0",
"react-router-dom": "^6.2.2", "react-router-dom": "^6.2.2",
"stacktrace-gps": "^3.0.4", "stacktrace-gps": "^3.0.4",
"stacktrace-js": "^2.0.2" "stacktrace-js": "^2.0.2",
"stylis": "^4.3.0",
"stylis-plugin-rtl": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.0.0", "@vitejs/plugin-react": "^4.0.0",
@ -1765,6 +1768,11 @@
"stylis": "4.2.0" "stylis": "4.2.0"
} }
}, },
"node_modules/@emotion/babel-plugin/node_modules/stylis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="
},
"node_modules/@emotion/cache": { "node_modules/@emotion/cache": {
"version": "11.11.0", "version": "11.11.0",
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz",
@ -1777,6 +1785,11 @@
"stylis": "4.2.0" "stylis": "4.2.0"
} }
}, },
"node_modules/@emotion/cache/node_modules/stylis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="
},
"node_modules/@emotion/hash": { "node_modules/@emotion/hash": {
"version": "0.9.1", "version": "0.9.1",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz",
@ -3314,6 +3327,14 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/cssjanus": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cssjanus/-/cssjanus-2.1.0.tgz",
"integrity": "sha512-kAijbny3GmdOi9k+QT6DGIXqFvL96aksNlGr4Rhk9qXDZYWUojU4bRc3IHWxdaLNOqgEZHuXoe5Wl2l7dxLW5g==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
@ -6351,9 +6372,20 @@
} }
}, },
"node_modules/stylis": { "node_modules/stylis": {
"version": "4.2.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" "integrity": "sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ=="
},
"node_modules/stylis-plugin-rtl": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/stylis-plugin-rtl/-/stylis-plugin-rtl-2.1.1.tgz",
"integrity": "sha512-q6xIkri6fBufIO/sV55md2CbgS5c6gg9EhSVATtHHCdOnbN/jcI0u3lYhNVeuI65c4lQPo67g8xmq5jrREvzlg==",
"dependencies": {
"cssjanus": "^2.0.1"
},
"peerDependencies": {
"stylis": "4.x"
}
}, },
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "5.5.0", "version": "5.5.0",

View File

@ -11,6 +11,7 @@
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/" "lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/"
}, },
"dependencies": { "dependencies": {
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.0", "@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.4.2", "@mui/icons-material": "^5.4.2",
@ -28,7 +29,9 @@
"react-infinite-scroll-component": "^6.1.0", "react-infinite-scroll-component": "^6.1.0",
"react-router-dom": "^6.2.2", "react-router-dom": "^6.2.2",
"stacktrace-gps": "^3.0.4", "stacktrace-gps": "^3.0.4",
"stacktrace-js": "^2.0.2" "stacktrace-js": "^2.0.2",
"stylis": "^4.3.0",
"stylis-plugin-rtl": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.0.0", "@vitejs/plugin-react": "^4.0.0",

View File

@ -130,13 +130,14 @@ export const hashCode = (s) => {
return hash; return hash;
}; };
export const formatShortDateTime = (timestamp) => export const formatShortDateTime = (timestamp, language) =>
new Intl.DateTimeFormat("default", { new Intl.DateTimeFormat(language, {
dateStyle: "short", dateStyle: "short",
timeStyle: "short", timeStyle: "short",
}).format(new Date(timestamp * 1000)); }).format(new Date(timestamp * 1000));
export const formatShortDate = (timestamp) => new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000)); export const formatShortDate = (timestamp, language) =>
new Intl.DateTimeFormat(language, { dateStyle: "short" }).format(new Date(timestamp * 1000));
export const formatBytes = (bytes, decimals = 2) => { export const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return "0 bytes"; if (bytes === 0) return "0 bytes";

View File

@ -39,7 +39,6 @@ import EditIcon from "@mui/icons-material/Edit";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import i18n from "i18next";
import humanizeDuration from "humanize-duration"; import humanizeDuration from "humanize-duration";
import CelebrationIcon from "@mui/icons-material/Celebration"; import CelebrationIcon from "@mui/icons-material/Celebration";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
@ -224,7 +223,7 @@ const ChangePasswordDialog = (props) => {
}; };
const AccountType = () => { const AccountType = () => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
const [upgradeDialogKey, setUpgradeDialogKey] = useState(0); const [upgradeDialogKey, setUpgradeDialogKey] = useState(0);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
@ -283,7 +282,7 @@ const AccountType = () => {
{account.billing?.paid_until && !account.billing?.cancel_at && ( {account.billing?.paid_until && !account.billing?.cancel_at && (
<Tooltip <Tooltip
title={t("account_basics_tier_paid_until", { title={t("account_basics_tier_paid_until", {
date: formatShortDate(account.billing?.paid_until), date: formatShortDate(account.billing?.paid_until, i18n.language),
})} })}
> >
<span> <span>
@ -328,7 +327,7 @@ const AccountType = () => {
{account.billing?.cancel_at > 0 && ( {account.billing?.cancel_at > 0 && (
<Alert severity="warning" sx={{ mt: 1 }}> <Alert severity="warning" sx={{ mt: 1 }}>
{t("account_basics_tier_canceled_subscription", { {t("account_basics_tier_canceled_subscription", {
date: formatShortDate(account.billing.cancel_at), date: formatShortDate(account.billing.cancel_at, i18n.language),
})} })}
</Alert> </Alert>
)} )}
@ -556,7 +555,7 @@ const AddPhoneNumberDialog = (props) => {
}; };
const Stats = () => { const Stats = () => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
if (!account) { if (!account) {
@ -798,7 +797,7 @@ const Tokens = () => {
}; };
const TokensTable = (props) => { const TokensTable = (props) => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const [snackOpen, setSnackOpen] = useState(false); const [snackOpen, setSnackOpen] = useState(false);
const [upsertDialogKey, setUpsertDialogKey] = useState(0); const [upsertDialogKey, setUpsertDialogKey] = useState(0);
const [upsertDialogOpen, setUpsertDialogOpen] = useState(false); const [upsertDialogOpen, setUpsertDialogOpen] = useState(false);
@ -872,11 +871,11 @@ const TokensTable = (props) => {
{token.token !== session.token() && (token.label || "-")} {token.token !== session.token() && (token.label || "-")}
</TableCell> </TableCell>
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}> <TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
{token.expires ? formatShortDateTime(token.expires) : <em>{t("account_tokens_table_never_expires")}</em>} {token.expires ? formatShortDateTime(token.expires, i18n.language) : <em>{t("account_tokens_table_never_expires")}</em>}
</TableCell> </TableCell>
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}> <TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
<div style={{ display: "flex", alignItems: "center" }}> <div style={{ display: "flex", alignItems: "center" }}>
<span>{formatShortDateTime(token.last_access)}</span> <span>{formatShortDateTime(token.last_access, i18n.language)}</span>
<Tooltip <Tooltip
title={t("account_tokens_table_last_origin_tooltip", { title={t("account_tokens_table_last_origin_tooltip", {
ip: token.last_origin, ip: token.last_origin,

View File

@ -3,6 +3,7 @@ import { createContext, Suspense, useContext, useEffect, useState, useMemo } fro
import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from "@mui/material"; import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from "@mui/material";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom"; import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { AllSubscriptions, SingleSubscription } from "./Notifications"; import { AllSubscriptions, SingleSubscription } from "./Notifications";
import { darkTheme, lightTheme } from "./theme"; import { darkTheme, lightTheme } from "./theme";
import Navigation from "./Navigation"; import Navigation from "./Navigation";
@ -21,6 +22,7 @@ import Signup from "./Signup";
import Account from "./Account"; import Account from "./Account";
import "../app/i18n"; // Translations! import "../app/i18n"; // Translations!
import prefs, { THEME } from "../app/Prefs"; import prefs, { THEME } from "../app/Prefs";
import RTLCacheProvider from "./RTLCacheProvider";
export const AccountContext = createContext(null); export const AccountContext = createContext(null);
@ -39,37 +41,47 @@ const darkModeEnabled = (prefersDarkMode, themePreference) => {
}; };
const App = () => { const App = () => {
const { i18n } = useTranslation();
const languageDir = i18n.dir();
const [account, setAccount] = useState(null); const [account, setAccount] = useState(null);
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]); const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const themePreference = useLiveQuery(() => prefs.theme()); const themePreference = useLiveQuery(() => prefs.theme());
const theme = React.useMemo( const theme = React.useMemo(
() => createTheme(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), () => createTheme({ ...(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), direction: languageDir }),
[prefersDarkMode, themePreference] [prefersDarkMode, themePreference, languageDir]
); );
useEffect(() => {
document.documentElement.setAttribute("lang", i18n.language);
document.dir = languageDir;
}, [i18n.language, languageDir]);
return ( return (
<Suspense fallback={<Loader />}> <Suspense fallback={<Loader />}>
<BrowserRouter> <RTLCacheProvider>
<ThemeProvider theme={theme}> <BrowserRouter>
<AccountContext.Provider value={accountMemo}> <ThemeProvider theme={theme}>
<CssBaseline /> <AccountContext.Provider value={accountMemo}>
<ErrorBoundary> <CssBaseline />
<Routes> <ErrorBoundary>
<Route path={routes.login} element={<Login />} /> <Routes>
<Route path={routes.signup} element={<Signup />} /> <Route path={routes.login} element={<Login />} />
<Route element={<Layout />}> <Route path={routes.signup} element={<Signup />} />
<Route path={routes.app} element={<AllSubscriptions />} /> <Route element={<Layout />}>
<Route path={routes.account} element={<Account />} /> <Route path={routes.app} element={<AllSubscriptions />} />
<Route path={routes.settings} element={<Preferences />} /> <Route path={routes.account} element={<Account />} />
<Route path={routes.subscription} element={<SingleSubscription />} /> <Route path={routes.settings} element={<Preferences />} />
<Route path={routes.subscriptionExternal} element={<SingleSubscription />} /> <Route path={routes.subscription} element={<SingleSubscription />} />
</Route> <Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
</Routes> </Route>
</ErrorBoundary> </Routes>
</AccountContext.Provider> </ErrorBoundary>
</ThemeProvider> </AccountContext.Provider>
</BrowserRouter> </ThemeProvider>
</BrowserRouter>
</RTLCacheProvider>
</Suspense> </Suspense>
); );
}; };

View File

@ -160,10 +160,10 @@ const autolink = (s) => {
}; };
const NotificationItem = (props) => { const NotificationItem = (props) => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const { notification } = props; const { notification } = props;
const { attachment } = notification; const { attachment } = notification;
const date = formatShortDateTime(notification.time); const date = formatShortDateTime(notification.time, i18n.language);
const otherTags = unmatchedTags(notification.tags); const otherTags = unmatchedTags(notification.tags);
const tags = otherTags.length > 0 ? otherTags.join(", ") : null; const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
const handleDelete = async () => { const handleDelete = async () => {
@ -277,7 +277,7 @@ const NotificationItem = (props) => {
}; };
const Attachment = (props) => { const Attachment = (props) => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const { attachment } = props; const { attachment } = props;
const expired = attachment.expires && attachment.expires < Date.now() / 1000; const expired = attachment.expires && attachment.expires < Date.now() / 1000;
const expires = attachment.expires && attachment.expires > Date.now() / 1000; const expires = attachment.expires && attachment.expires > Date.now() / 1000;
@ -296,7 +296,7 @@ const Attachment = (props) => {
if (expires) { if (expires) {
infos.push( infos.push(
t("notifications_attachment_link_expires", { t("notifications_attachment_link_expires", {
date: formatShortDateTime(attachment.expires), date: formatShortDateTime(attachment.expires, i18n.language),
}) })
); );
} }

View File

@ -0,0 +1,22 @@
import React from "react";
import rtlPlugin from "stylis-plugin-rtl";
import { CacheProvider } from "@emotion/react";
import createCache from "@emotion/cache";
import { prefixer } from "stylis";
import { useTranslation } from "react-i18next";
// https://mui.com/material-ui/guides/right-to-left
const cacheRtl = createCache({
key: "muirtl",
stylisPlugins: [prefixer, rtlPlugin],
});
const RTLCacheProvider = ({ children }) => {
const { i18n } = useTranslation();
return i18n.dir() === "rtl" ? <CacheProvider value={cacheRtl}>{children}</CacheProvider> : children;
};
export default RTLCacheProvider;

View File

@ -117,10 +117,16 @@ export const SubscriptionPopup = (props) => {
])[0]; ])[0];
const nowSeconds = Math.round(Date.now() / 1000); const nowSeconds = Math.round(Date.now() / 1000);
const message = shuffle([ const message = shuffle([
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`, `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(
nowSeconds,
"en-US"
)} 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.`, `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.`, `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(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`, `Alright then, it's ${formatShortDateTime(
nowSeconds,
"en-US"
)} 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 ...`, `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.`, `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.`,
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`, `It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`,

View File

@ -62,7 +62,7 @@ const Banner = {
const UpgradeDialog = (props) => { const UpgradeDialog = (props) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const { account } = useContext(AccountContext); // May be undefined! const { account } = useContext(AccountContext); // May be undefined!
const [error, setError] = useState(""); const [error, setError] = useState("");
const [tiers, setTiers] = useState(null); const [tiers, setTiers] = useState(null);
@ -233,7 +233,7 @@ const UpgradeDialog = (props) => {
<Trans <Trans
i18nKey="account_upgrade_dialog_cancel_warning" i18nKey="account_upgrade_dialog_cancel_warning"
values={{ values={{
date: formatShortDate(account?.billing?.paid_until || 0), date: formatShortDate(account?.billing?.paid_until || 0, i18n.language),
}} }}
/> />
</Alert> </Alert>