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 (
     <Suspense fallback={<Loader />}>
-      <BrowserRouter>
-        <ThemeProvider theme={theme}>
-          <AccountContext.Provider value={accountMemo}>
-            <CssBaseline />
-            <ErrorBoundary>
-              <Routes>
-                <Route path={routes.login} element={<Login />} />
-                <Route path={routes.signup} element={<Signup />} />
-                <Route element={<Layout />}>
-                  <Route path={routes.app} element={<AllSubscriptions />} />
-                  <Route path={routes.account} element={<Account />} />
-                  <Route path={routes.settings} element={<Preferences />} />
-                  <Route path={routes.subscription} element={<SingleSubscription />} />
-                  <Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
-                </Route>
-              </Routes>
-            </ErrorBoundary>
-          </AccountContext.Provider>
-        </ThemeProvider>
-      </BrowserRouter>
+      <RTLCacheProvider>
+        <BrowserRouter>
+          <ThemeProvider theme={theme}>
+            <AccountContext.Provider value={accountMemo}>
+              <CssBaseline />
+              <ErrorBoundary>
+                <Routes>
+                  <Route path={routes.login} element={<Login />} />
+                  <Route path={routes.signup} element={<Signup />} />
+                  <Route element={<Layout />}>
+                    <Route path={routes.app} element={<AllSubscriptions />} />
+                    <Route path={routes.account} element={<Account />} />
+                    <Route path={routes.settings} element={<Preferences />} />
+                    <Route path={routes.subscription} element={<SingleSubscription />} />
+                    <Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
+                  </Route>
+                </Routes>
+              </ErrorBoundary>
+            </AccountContext.Provider>
+          </ThemeProvider>
+        </BrowserRouter>
+      </RTLCacheProvider>
     </Suspense>
   );
 };
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" ? <CacheProvider value={cacheRtl}>{children}</CacheProvider> : 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 && (
           <Tooltip
             title={t("account_basics_tier_paid_until", {
-              date: formatShortDate(account.billing?.paid_until),
+              date: formatShortDate(account.billing?.paid_until, i18n.language),
             })}
           >
             <span>
@@ -328,7 +327,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),
+            date: formatShortDate(account.billing.cancel_at, i18n.language),
           })}
         </Alert>
       )}
@@ -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 || "-")}
             </TableCell>
             <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 sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
               <div style={{ display: "flex", alignItems: "center" }}>
-                <span>{formatShortDateTime(token.last_access)}</span>
+                <span>{formatShortDateTime(token.last_access, i18n.language)}</span>
                 <Tooltip
                   title={t("account_tokens_table_last_origin_tooltip", {
                     ip: token.last_origin,
diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx
index 85ced743..ea0cbc14 100644
--- a/web/src/components/Notifications.jsx
+++ b/web/src/components/Notifications.jsx
@@ -160,10 +160,10 @@ const autolink = (s) => {
 };
 
 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) => {
             <Trans
               i18nKey="account_upgrade_dialog_cancel_warning"
               values={{
-                date: formatShortDate(account?.billing?.paid_until || 0),
+                date: formatShortDate(account?.billing?.paid_until || 0, i18n.language),
               }}
             />
           </Alert>