From 7a1488fcd3f02f9d0828fc813f984d48c9b4b9d9 Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Mon, 3 Jul 2023 15:10:37 +0200 Subject: [PATCH 1/2] Web app: add RTL support Ref: https://mui.com/material-ui/guides/right-to-left https://m2.material.io/design/usability/bidirectionality.html --- web/package-lock.json | 40 ++++++++++++++++-- web/package.json | 5 ++- web/src/components/App.jsx | 56 +++++++++++++++---------- web/src/components/RTLCacheProvider.jsx | 22 ++++++++++ 4 files changed, 96 insertions(+), 27 deletions(-) create mode 100644 web/src/components/RTLCacheProvider.jsx diff --git a/web/package-lock.json b/web/package-lock.json index 8ee2635d..ef9680be 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "ntfy", "version": "1.0.0", "dependencies": { + "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.4.2", @@ -25,7 +26,9 @@ "react-infinite-scroll-component": "^6.1.0", "react-router-dom": "^6.2.2", "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": { "@vitejs/plugin-react": "^4.0.0", @@ -1765,6 +1768,11 @@ "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": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", @@ -1777,6 +1785,11 @@ "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": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", @@ -3314,6 +3327,14 @@ "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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", @@ -6351,9 +6372,20 @@ } }, "node_modules/stylis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", - "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.0.tgz", + "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": { "version": "5.5.0", diff --git a/web/package.json b/web/package.json index 2e52635a..400a090a 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,7 @@ "lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/" }, "dependencies": { + "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.4.2", @@ -28,7 +29,9 @@ "react-infinite-scroll-component": "^6.1.0", "react-router-dom": "^6.2.2", "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": { "@vitejs/plugin-react": "^4.0.0", diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 2ad7f419..8b60b3e8 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -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 { useLiveQuery } from "dexie-react-hooks"; import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { AllSubscriptions, SingleSubscription } from "./Notifications"; import { darkTheme, lightTheme } from "./theme"; import Navigation from "./Navigation"; @@ -21,6 +22,7 @@ import Signup from "./Signup"; import Account from "./Account"; import "../app/i18n"; // Translations! import prefs, { THEME } from "../app/Prefs"; +import RTLCacheProvider from "./RTLCacheProvider"; export const AccountContext = createContext(null); @@ -39,37 +41,47 @@ const darkModeEnabled = (prefersDarkMode, themePreference) => { }; const App = () => { + const { i18n } = useTranslation(); + const languageDir = i18n.dir(); + const [account, setAccount] = useState(null); const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]); const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); const themePreference = useLiveQuery(() => prefs.theme()); const theme = React.useMemo( - () => createTheme(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), - [prefersDarkMode, themePreference] + () => createTheme({ ...(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), direction: languageDir }), + [prefersDarkMode, themePreference, languageDir] ); + useEffect(() => { + document.documentElement.setAttribute("lang", i18n.language); + document.dir = languageDir; + }, [i18n.language, languageDir]); + return ( }> - - - - - - - } /> - } /> - }> - } /> - } /> - } /> - } /> - } /> - - - - - - + + + + + + + + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + + + + + + + ); }; diff --git a/web/src/components/RTLCacheProvider.jsx b/web/src/components/RTLCacheProvider.jsx new file mode 100644 index 00000000..a85fced6 --- /dev/null +++ b/web/src/components/RTLCacheProvider.jsx @@ -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" ? {children} : children; +}; + +export default RTLCacheProvider; From 311ffc36727146030b116b42234bb75fef4f17e2 Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Mon, 3 Jul 2023 15:24:13 +0200 Subject: [PATCH 2/2] Format datetimes using i18n lang --- web/src/app/utils.js | 7 ++++--- web/src/components/Account.jsx | 15 +++++++-------- web/src/components/Notifications.jsx | 8 ++++---- web/src/components/SubscriptionPopup.jsx | 10 ++++++++-- web/src/components/UpgradeDialog.jsx | 4 ++-- 5 files changed, 25 insertions(+), 19 deletions(-) diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 244d3321..39e7a3b2 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -130,13 +130,14 @@ export const hashCode = (s) => { return hash; }; -export const formatShortDateTime = (timestamp) => - new Intl.DateTimeFormat("default", { +export const formatShortDateTime = (timestamp, language) => + new Intl.DateTimeFormat(language, { dateStyle: "short", timeStyle: "short", }).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) => { if (bytes === 0) return "0 bytes"; diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 541a008d..319353df 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -39,7 +39,6 @@ import EditIcon from "@mui/icons-material/Edit"; import { Trans, useTranslation } from "react-i18next"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; -import i18n from "i18next"; import humanizeDuration from "humanize-duration"; import CelebrationIcon from "@mui/icons-material/Celebration"; import CloseIcon from "@mui/icons-material/Close"; @@ -224,7 +223,7 @@ const ChangePasswordDialog = (props) => { }; const AccountType = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { account } = useContext(AccountContext); const [upgradeDialogKey, setUpgradeDialogKey] = useState(0); const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); @@ -283,7 +282,7 @@ const AccountType = () => { {account.billing?.paid_until && !account.billing?.cancel_at && ( @@ -328,7 +327,7 @@ const AccountType = () => { {account.billing?.cancel_at > 0 && ( {t("account_basics_tier_canceled_subscription", { - date: formatShortDate(account.billing.cancel_at), + date: formatShortDate(account.billing.cancel_at, i18n.language), })} )} @@ -556,7 +555,7 @@ const AddPhoneNumberDialog = (props) => { }; const Stats = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { account } = useContext(AccountContext); if (!account) { @@ -798,7 +797,7 @@ const Tokens = () => { }; const TokensTable = (props) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [snackOpen, setSnackOpen] = useState(false); const [upsertDialogKey, setUpsertDialogKey] = useState(0); const [upsertDialogOpen, setUpsertDialogOpen] = useState(false); @@ -872,11 +871,11 @@ const TokensTable = (props) => { {token.token !== session.token() && (token.label || "-")} - {token.expires ? formatShortDateTime(token.expires) : {t("account_tokens_table_never_expires")}} + {token.expires ? formatShortDateTime(token.expires, i18n.language) : {t("account_tokens_table_never_expires")}}
- {formatShortDateTime(token.last_access)} + {formatShortDateTime(token.last_access, i18n.language)} { }; const NotificationItem = (props) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { notification } = props; const { attachment } = notification; - const date = formatShortDateTime(notification.time); + const date = formatShortDateTime(notification.time, i18n.language); const otherTags = unmatchedTags(notification.tags); const tags = otherTags.length > 0 ? otherTags.join(", ") : null; const handleDelete = async () => { @@ -277,7 +277,7 @@ const NotificationItem = (props) => { }; const Attachment = (props) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { attachment } = props; const expired = 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) { infos.push( t("notifications_attachment_link_expires", { - date: formatShortDateTime(attachment.expires), + date: formatShortDateTime(attachment.expires, i18n.language), }) ); } diff --git a/web/src/components/SubscriptionPopup.jsx b/web/src/components/SubscriptionPopup.jsx index 17b12504..1a6a689c 100644 --- a/web/src/components/SubscriptionPopup.jsx +++ b/web/src/components/SubscriptionPopup.jsx @@ -117,10 +117,16 @@ 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(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.`, `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 ...`, `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?`, diff --git a/web/src/components/UpgradeDialog.jsx b/web/src/components/UpgradeDialog.jsx index 4bf0244d..712c47ec 100644 --- a/web/src/components/UpgradeDialog.jsx +++ b/web/src/components/UpgradeDialog.jsx @@ -62,7 +62,7 @@ const Banner = { const UpgradeDialog = (props) => { const theme = useTheme(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { account } = useContext(AccountContext); // May be undefined! const [error, setError] = useState(""); const [tiers, setTiers] = useState(null); @@ -233,7 +233,7 @@ const UpgradeDialog = (props) => {