nimbleghost 2 лет назад
Родитель
Сommit
8319f1cf26

+ 20 - 20
web/src/app/AccountApi.js

@@ -1,3 +1,4 @@
+import i18n from "i18next";
 import {
   accountBillingPortalUrl,
   accountBillingSubscriptionUrl,
@@ -17,7 +18,6 @@ import {
 } from "./utils";
 import session from "./Session";
 import subscriptionManager from "./SubscriptionManager";
-import i18n from "i18next";
 import prefs from "./Prefs";
 import routes from "../components/routes";
 import { fetchOrThrow, UnauthorizedError } from "./errors";
@@ -66,13 +66,13 @@ class AccountApi {
   async create(username, password) {
     const url = accountUrl(config.base_url);
     const body = JSON.stringify({
-      username: username,
-      password: password,
+      username,
+      password,
     });
     console.log(`[AccountApi] Creating user account ${url}`);
     await fetchOrThrow(url, {
       method: "POST",
-      body: body,
+      body,
     });
   }
 
@@ -97,7 +97,7 @@ class AccountApi {
       method: "DELETE",
       headers: withBearerAuth({}, session.token()),
       body: JSON.stringify({
-        password: password,
+        password,
       }),
     });
   }
@@ -118,7 +118,7 @@ class AccountApi {
   async createToken(label, expires) {
     const url = accountTokenUrl(config.base_url);
     const body = {
-      label: label,
+      label,
       expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0,
     };
     console.log(`[AccountApi] Creating user access token ${url}`);
@@ -132,8 +132,8 @@ class AccountApi {
   async updateToken(token, label, expires) {
     const url = accountTokenUrl(config.base_url);
     const body = {
-      token: token,
-      label: label,
+      token,
+      label,
     };
     if (expires > 0) {
       body.expires = Math.floor(Date.now() / 1000) + expires;
@@ -171,7 +171,7 @@ class AccountApi {
     await fetchOrThrow(url, {
       method: "PATCH",
       headers: withBearerAuth({}, session.token()),
-      body: body,
+      body,
     });
   }
 
@@ -179,13 +179,13 @@ class AccountApi {
     const url = accountSubscriptionUrl(config.base_url);
     const body = JSON.stringify({
       base_url: baseUrl,
-      topic: topic,
+      topic,
     });
     console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
     const response = await fetchOrThrow(url, {
       method: "POST",
       headers: withBearerAuth({}, session.token()),
-      body: body,
+      body,
     });
     const subscription = await response.json(); // May throw SyntaxError
     console.log(`[AccountApi] Subscription`, subscription);
@@ -196,14 +196,14 @@ class AccountApi {
     const url = accountSubscriptionUrl(config.base_url);
     const body = JSON.stringify({
       base_url: baseUrl,
-      topic: topic,
+      topic,
       ...payload,
     });
     console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
     const response = await fetchOrThrow(url, {
       method: "PATCH",
       headers: withBearerAuth({}, session.token()),
-      body: body,
+      body,
     });
     const subscription = await response.json(); // May throw SyntaxError
     console.log(`[AccountApi] Subscription`, subscription);
@@ -230,8 +230,8 @@ class AccountApi {
       method: "POST",
       headers: withBearerAuth({}, session.token()),
       body: JSON.stringify({
-        topic: topic,
-        everyone: everyone,
+        topic,
+        everyone,
       }),
     });
   }
@@ -272,11 +272,11 @@ class AccountApi {
   async upsertBillingSubscription(method, tier, interval) {
     const url = accountBillingSubscriptionUrl(config.base_url);
     const response = await fetchOrThrow(url, {
-      method: method,
+      method,
       headers: withBearerAuth({}, session.token()),
       body: JSON.stringify({
-        tier: tier,
-        interval: interval,
+        tier,
+        interval,
       }),
     });
     return await response.json(); // May throw SyntaxError
@@ -309,7 +309,7 @@ class AccountApi {
       headers: withBearerAuth({}, session.token()),
       body: JSON.stringify({
         number: phoneNumber,
-        channel: channel,
+        channel,
       }),
     });
   }
@@ -322,7 +322,7 @@ class AccountApi {
       headers: withBearerAuth({}, session.token()),
       body: JSON.stringify({
         number: phoneNumber,
-        code: code,
+        code,
       }),
     });
   }

+ 6 - 5
web/src/app/Api.js

@@ -18,7 +18,7 @@ class Api {
     const messages = [];
     const headers = maybeWithAuth({}, user);
     console.log(`[Api] Polling ${url}`);
-    for await (let line of fetchLinesIterator(url, headers)) {
+    for await (const line of fetchLinesIterator(url, headers)) {
       const message = JSON.parse(line);
       if (message.id) {
         console.log(`[Api, ${shortUrl}] Received message ${line}`);
@@ -33,8 +33,8 @@ class Api {
     console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
     const headers = {};
     const body = {
-      topic: topic,
-      message: message,
+      topic,
+      message,
       ...options,
     };
     await fetchOrThrow(baseUrl, {
@@ -60,7 +60,7 @@ class Api {
   publishXHR(url, body, headers, onProgress) {
     console.log(`[Api] Publishing message to ${url}`);
     const xhr = new XMLHttpRequest();
-    const send = new Promise(function (resolve, reject) {
+    const send = new Promise((resolve, reject) => {
       xhr.open("PUT", url);
       if (body.type) {
         xhr.overrideMimeType(body.type);
@@ -106,7 +106,8 @@ class Api {
     });
     if (response.status >= 200 && response.status <= 299) {
       return true;
-    } else if (response.status === 401 || response.status === 403) {
+    }
+    if (response.status === 401 || response.status === 403) {
       // See server/server.go
       return false;
     }

+ 2 - 1
web/src/app/Connection.js

@@ -77,7 +77,7 @@ class Connection {
   close() {
     console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`);
     const socket = this.ws;
-    const retryTimeout = this.retryTimeout;
+    const { retryTimeout } = this;
     if (socket !== null) {
       socket.close();
     }
@@ -110,6 +110,7 @@ class Connection {
 
 export class ConnectionState {
   static Connected = "connected";
+
   static Connecting = "connecting";
 }
 

+ 6 - 7
web/src/app/ConnectionManager.js

@@ -55,12 +55,12 @@ class ConnectionManager {
     // Create and add new connections
     subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
       const subscriptionId = subscription.id;
-      const connectionId = subscription.connectionId;
+      const { connectionId } = subscription;
       const added = !this.connections.get(connectionId);
       if (added) {
-        const baseUrl = subscription.baseUrl;
-        const topic = subscription.topic;
-        const user = subscription.user;
+        const { baseUrl } = subscription;
+        const { topic } = subscription;
+        const { user } = subscription;
         const since = subscription.last;
         const connection = new Connection(
           connectionId,
@@ -112,9 +112,8 @@ class ConnectionManager {
   }
 }
 
-const makeConnectionId = async (subscription, user) => {
-  return user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`);
-};
+const makeConnectionId = async (subscription, user) =>
+  user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`);
 
 const connectionManager = new ConnectionManager();
 export default connectionManager;

+ 11 - 11
web/src/app/SubscriptionManager.js

@@ -25,8 +25,8 @@ class SubscriptionManager {
     }
     const subscription = {
       id: topicUrl(baseUrl, topic),
-      baseUrl: baseUrl,
-      topic: topic,
+      baseUrl,
+      topic,
       mutedUntil: 0,
       last: null,
       internal: internal || false,
@@ -39,14 +39,14 @@ class SubscriptionManager {
     console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
 
     // Add remote subscriptions
-    let remoteIds = []; // = topicUrl(baseUrl, topic)
+    const remoteIds = []; // = topicUrl(baseUrl, topic)
     for (let i = 0; i < remoteSubscriptions.length; i++) {
       const remote = remoteSubscriptions[i];
       const local = await this.add(remote.base_url, remote.topic, false);
       const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
       await this.update(local.id, {
         displayName: remote.display_name, // May be undefined
-        reservation: reservation, // May be null!
+        reservation, // May be null!
       });
       remoteIds.push(local.id);
     }
@@ -63,12 +63,12 @@ class SubscriptionManager {
   }
 
   async updateState(subscriptionId, state) {
-    db.subscriptions.update(subscriptionId, { state: state });
+    db.subscriptions.update(subscriptionId, { state });
   }
 
   async remove(subscriptionId) {
     await db.subscriptions.delete(subscriptionId);
-    await db.notifications.where({ subscriptionId: subscriptionId }).delete();
+    await db.notifications.where({ subscriptionId }).delete();
   }
 
   async first() {
@@ -140,7 +140,7 @@ class SubscriptionManager {
   }
 
   async deleteNotifications(subscriptionId) {
-    await db.notifications.where({ subscriptionId: subscriptionId }).delete();
+    await db.notifications.where({ subscriptionId }).delete();
   }
 
   async markNotificationRead(notificationId) {
@@ -148,24 +148,24 @@ class SubscriptionManager {
   }
 
   async markNotificationsRead(subscriptionId) {
-    await db.notifications.where({ subscriptionId: subscriptionId, new: 1 }).modify({ new: 0 });
+    await db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
   }
 
   async setMutedUntil(subscriptionId, mutedUntil) {
     await db.subscriptions.update(subscriptionId, {
-      mutedUntil: mutedUntil,
+      mutedUntil,
     });
   }
 
   async setDisplayName(subscriptionId, displayName) {
     await db.subscriptions.update(subscriptionId, {
-      displayName: displayName,
+      displayName,
     });
   }
 
   async setReservation(subscriptionId, reservation) {
     await db.subscriptions.update(subscriptionId, {
-      reservation: reservation,
+      reservation,
     });
   }
 

+ 1 - 1
web/src/app/config.js

@@ -1,4 +1,4 @@
-const config = window.config;
+const { config } = window;
 
 // The backend returns an empty base_url for the config struct,
 // so the frontend (hey, that's us!) can use the current location.

+ 4 - 0
web/src/app/errors.js

@@ -48,6 +48,7 @@ export class UnauthorizedError extends Error {
 
 export class UserExistsError extends Error {
   static CODE = 40901; // errHTTPConflictUserExists
+
   constructor() {
     super("Username already exists");
   }
@@ -55,6 +56,7 @@ export class UserExistsError extends Error {
 
 export class TopicReservedError extends Error {
   static CODE = 40902; // errHTTPConflictTopicReserved
+
   constructor() {
     super("Topic already reserved");
   }
@@ -62,6 +64,7 @@ export class TopicReservedError extends Error {
 
 export class AccountCreateLimitReachedError extends Error {
   static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
+
   constructor() {
     super("Account creation limit reached");
   }
@@ -69,6 +72,7 @@ export class AccountCreateLimitReachedError extends Error {
 
 export class IncorrectPasswordError extends Error {
   static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation
+
   constructor() {
     super("Password incorrect");
   }

+ 36 - 52
web/src/app/utils.js

@@ -1,3 +1,4 @@
+import { Base64 } from "js-base64";
 import { rawEmojis } from "./emojis";
 import beep from "../sounds/beep.mp3";
 import juntos from "../sounds/juntos.mp3";
@@ -7,7 +8,6 @@ import dadum from "../sounds/dadum.mp3";
 import pop from "../sounds/pop.mp3";
 import popSwoosh from "../sounds/pop-swoosh.mp3";
 import config from "./config";
-import { Base64 } from "js-base64";
 
 export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
 export const topicUrlWs = (baseUrl, topic) =>
@@ -33,9 +33,7 @@ export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
 export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
 export const expandSecureUrl = (url) => `https://${url}`;
 
-export const validUrl = (url) => {
-  return url.match(/^https?:\/\/.+/);
-};
+export const validUrl = (url) => url.match(/^https?:\/\/.+/);
 
 export const validTopic = (topic) => {
   if (disallowedTopic(topic)) {
@@ -44,14 +42,13 @@ export const validTopic = (topic) => {
   return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
 };
 
-export const disallowedTopic = (topic) => {
-  return config.disallowed_topics.includes(topic);
-};
+export const disallowedTopic = (topic) => config.disallowed_topics.includes(topic);
 
 export const topicDisplayName = (subscription) => {
   if (subscription.displayName) {
     return subscription.displayName;
-  } else if (subscription.baseUrl === config.base_url) {
+  }
+  if (subscription.baseUrl === config.base_url) {
     return subscription.topic;
   }
   return topicShortUrl(subscription.baseUrl, subscription.topic);
@@ -67,7 +64,7 @@ rawEmojis.forEach((emoji) => {
 
 const toEmojis = (tags) => {
   if (!tags) return [];
-  else return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]);
+  return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]);
 };
 
 export const formatTitleWithDefault = (m, fallback) => {
@@ -81,33 +78,31 @@ export const formatTitle = (m) => {
   const emojiList = toEmojis(m.tags);
   if (emojiList.length > 0) {
     return `${emojiList.join(" ")} ${m.title}`;
-  } else {
-    return m.title;
   }
+  return m.title;
 };
 
 export const formatMessage = (m) => {
   if (m.title) {
     return m.message;
-  } else {
-    const emojiList = toEmojis(m.tags);
-    if (emojiList.length > 0) {
-      return `${emojiList.join(" ")} ${m.message}`;
-    } else {
-      return m.message;
-    }
   }
+  const emojiList = toEmojis(m.tags);
+  if (emojiList.length > 0) {
+    return `${emojiList.join(" ")} ${m.message}`;
+  }
+  return m.message;
 };
 
 export const unmatchedTags = (tags) => {
   if (!tags) return [];
-  else return tags.filter((tag) => !(tag in emojis));
+  return tags.filter((tag) => !(tag in emojis));
 };
 
 export const maybeWithAuth = (headers, user) => {
   if (user && user.password) {
     return withBasicAuth(headers, user.username, user.password);
-  } else if (user && user.token) {
+  }
+  if (user && user.token) {
     return withBearerAuth(headers, user.token);
   }
   return headers;
@@ -121,30 +116,22 @@ export const maybeWithBearerAuth = (headers, token) => {
 };
 
 export const withBasicAuth = (headers, username, password) => {
-  headers["Authorization"] = basicAuth(username, password);
+  headers.Authorization = basicAuth(username, password);
   return headers;
 };
 
-export const basicAuth = (username, password) => {
-  return `Basic ${encodeBase64(`${username}:${password}`)}`;
-};
+export const basicAuth = (username, password) => `Basic ${encodeBase64(`${username}:${password}`)}`;
 
 export const withBearerAuth = (headers, token) => {
-  headers["Authorization"] = bearerAuth(token);
+  headers.Authorization = bearerAuth(token);
   return headers;
 };
 
-export const bearerAuth = (token) => {
-  return `Bearer ${token}`;
-};
+export const bearerAuth = (token) => `Bearer ${token}`;
 
-export const encodeBase64 = (s) => {
-  return Base64.encode(s);
-};
+export const encodeBase64 = (s) => Base64.encode(s);
 
-export const encodeBase64Url = (s) => {
-  return Base64.encodeURI(s);
-};
+export const encodeBase64Url = (s) => Base64.encodeURI(s);
 
 export const maybeAppendActionErrors = (message, notification) => {
   const actionErrors = (notification.actions ?? [])
@@ -153,13 +140,13 @@ export const maybeAppendActionErrors = (message, notification) => {
     .join("\n");
   if (actionErrors.length === 0) {
     return message;
-  } else {
-    return `${message}\n\n${actionErrors}`;
   }
+  return `${message}\n\n${actionErrors}`;
 };
 
 export const shuffle = (arr) => {
-  let j, x;
+  let j;
+  let x;
   for (let index = arr.length - 1; index > 0; index--) {
     j = Math.floor(Math.random() * (index + 1));
     x = arr[index];
@@ -169,12 +156,11 @@ export const shuffle = (arr) => {
   return arr;
 };
 
-export const splitNoEmpty = (s, delimiter) => {
-  return s
+export const splitNoEmpty = (s, delimiter) =>
+  s
     .split(delimiter)
     .map((x) => x.trim())
     .filter((x) => x !== "");
-};
 
 /** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
 export const hashCode = async (s) => {
@@ -182,21 +168,18 @@ export const hashCode = async (s) => {
   for (let i = 0; i < s.length; i++) {
     const char = s.charCodeAt(i);
     hash = (hash << 5) - hash + char;
-    hash = hash & hash; // Convert to 32bit integer
+    hash &= hash; // Convert to 32bit integer
   }
   return hash;
 };
 
-export const formatShortDateTime = (timestamp) => {
-  return new Intl.DateTimeFormat("default", {
+export const formatShortDateTime = (timestamp) =>
+  new Intl.DateTimeFormat("default", {
     dateStyle: "short",
     timeStyle: "short",
   }).format(new Date(timestamp * 1000));
-};
 
-export const formatShortDate = (timestamp) => {
-  return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000));
-};
+export const formatShortDate = (timestamp) => new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000));
 
 export const formatBytes = (bytes, decimals = 2) => {
   if (bytes === 0) return "0 bytes";
@@ -204,13 +187,14 @@ export const formatBytes = (bytes, decimals = 2) => {
   const dm = decimals < 0 ? 0 : decimals;
   const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
   const i = Math.floor(Math.log(bytes) / Math.log(k));
-  return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
+  return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
 };
 
 export const formatNumber = (n) => {
   if (n === 0) {
     return n;
-  } else if (n % 1000 === 0) {
+  }
+  if (n % 1000 === 0) {
     return `${n / 1000}k`;
   }
   return n.toLocaleString();
@@ -267,7 +251,7 @@ export const playSound = async (id) => {
 export async function* fetchLinesIterator(fileURL, headers) {
   const utf8Decoder = new TextDecoder("utf-8");
   const response = await fetch(fileURL, {
-    headers: headers,
+    headers,
   });
   const reader = response.body.getReader();
   let { value: chunk, done: readerDone } = await reader.read();
@@ -277,12 +261,12 @@ export async function* fetchLinesIterator(fileURL, headers) {
   let startIndex = 0;
 
   for (;;) {
-    let result = re.exec(chunk);
+    const result = re.exec(chunk);
     if (!result) {
       if (readerDone) {
         break;
       }
-      let remainder = chunk.substr(startIndex);
+      const remainder = chunk.substr(startIndex);
       ({ value: chunk, done: readerDone } = await reader.read());
       chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : "");
       startIndex = re.lastIndex = 0;

+ 25 - 28
web/src/components/Account.jsx

@@ -29,34 +29,34 @@ import Container from "@mui/material/Container";
 import Card from "@mui/material/Card";
 import Button from "@mui/material/Button";
 import { Trans, useTranslation } from "react-i18next";
-import session from "../app/Session";
 import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
-import theme from "./theme";
 import Dialog from "@mui/material/Dialog";
 import DialogTitle from "@mui/material/DialogTitle";
 import DialogContent from "@mui/material/DialogContent";
 import TextField from "@mui/material/TextField";
-import routes from "./routes";
 import IconButton from "@mui/material/IconButton";
-import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
-import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
 import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
-import { Pref, PrefGroup } from "./Pref";
-import db from "../app/db";
 import i18n from "i18next";
 import humanizeDuration from "humanize-duration";
-import UpgradeDialog from "./UpgradeDialog";
 import CelebrationIcon from "@mui/icons-material/Celebration";
-import { AccountContext } from "./App";
-import DialogFooter from "./DialogFooter";
-import { Paragraph } from "./styles";
 import CloseIcon from "@mui/icons-material/Close";
 import { ContentCopy, Public } from "@mui/icons-material";
 import MenuItem from "@mui/material/MenuItem";
 import DialogContentText from "@mui/material/DialogContentText";
+import AddIcon from "@mui/icons-material/Add";
+import routes from "./routes";
+import { formatBytes, formatShortDate, formatShortDateTime, 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 "./App";
+import DialogFooter from "./DialogFooter";
+import { Paragraph } from "./styles";
 import { IncorrectPasswordError, UnauthorizedError } from "../app/errors";
 import { ProChip } from "./SubscriptionPopup";
-import AddIcon from "@mui/icons-material/Add";
+import theme from "./theme";
+import session from "../app/Session";
 
 const Account = () => {
   if (!session.exists()) {
@@ -561,9 +561,7 @@ const Stats = () => {
     return <></>;
   }
 
-  const normalize = (value, max) => {
-    return Math.min((value / max) * 100, 100);
-  };
+  const normalize = (value, max) => Math.min((value / max) * 100, 100);
 
   return (
     <Card sx={{ p: 3 }} aria-label={t("account_usage_title")}>
@@ -746,18 +744,16 @@ const Stats = () => {
   );
 };
 
-const InfoIcon = () => {
-  return (
-    <InfoOutlinedIcon
-      sx={{
-        verticalAlign: "middle",
-        width: "18px",
-        marginLeft: "4px",
-        color: "gray",
-      }}
-    />
-  );
-};
+const InfoIcon = () => (
+  <InfoOutlinedIcon
+    sx={{
+      verticalAlign: "middle",
+      width: "18px",
+      marginLeft: "4px",
+      color: "gray",
+    }}
+  />
+);
 
 const Tokens = () => {
   const { t } = useTranslation();
@@ -814,7 +810,8 @@ const TokensTable = (props) => {
   const tokens = (props.tokens || []).sort((a, b) => {
     if (a.token === session.token()) {
       return -1;
-    } else if (b.token === session.token()) {
+    }
+    if (b.token === session.token()) {
       return 1;
     }
     return a.token.localeCompare(b.token);

+ 8 - 8
web/src/components/ActionBar.jsx

@@ -1,5 +1,4 @@
 import AppBar from "@mui/material/AppBar";
-import Navigation from "./Navigation";
 import Toolbar from "@mui/material/Toolbar";
 import IconButton from "@mui/material/IconButton";
 import MenuIcon from "@mui/icons-material/Menu";
@@ -7,23 +6,24 @@ import Typography from "@mui/material/Typography";
 import * as React from "react";
 import { useState } from "react";
 import Box from "@mui/material/Box";
-import { topicDisplayName } from "../app/utils";
-import db from "../app/db";
 import { useLocation, useNavigate } from "react-router-dom";
 import MenuItem from "@mui/material/MenuItem";
 import MoreVertIcon from "@mui/icons-material/MoreVert";
 import NotificationsIcon from "@mui/icons-material/Notifications";
 import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
-import routes from "./routes";
-import subscriptionManager from "../app/SubscriptionManager";
-import logo from "../img/ntfy.svg";
 import { useTranslation } from "react-i18next";
-import session from "../app/Session";
 import AccountCircleIcon from "@mui/icons-material/AccountCircle";
 import Button from "@mui/material/Button";
 import Divider from "@mui/material/Divider";
 import { Logout, Person, Settings } from "@mui/icons-material";
 import ListItemIcon from "@mui/material/ListItemIcon";
+import session from "../app/Session";
+import logo from "../img/ntfy.svg";
+import subscriptionManager from "../app/SubscriptionManager";
+import routes from "./routes";
+import db from "../app/db";
+import { topicDisplayName } from "../app/utils";
+import Navigation from "./Navigation";
 import accountApi from "../app/AccountApi";
 import PopupMenu from "./PopupMenu";
 import { SubscriptionPopup } from "./SubscriptionPopup";
@@ -86,7 +86,7 @@ const ActionBar = (props) => {
 const SettingsIcons = (props) => {
   const { t } = useTranslation();
   const [anchorEl, setAnchorEl] = useState(null);
-  const subscription = props.subscription;
+  const { subscription } = props;
 
   const handleToggleMute = async () => {
     const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future

+ 26 - 29
web/src/components/App.jsx

@@ -4,16 +4,17 @@ import Box from "@mui/material/Box";
 import { ThemeProvider } from "@mui/material/styles";
 import CssBaseline from "@mui/material/CssBaseline";
 import Toolbar from "@mui/material/Toolbar";
+import { useLiveQuery } from "dexie-react-hooks";
+import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
+import { Backdrop, CircularProgress } from "@mui/material";
 import { AllSubscriptions, SingleSubscription } from "./Notifications";
 import theme from "./theme";
 import Navigation from "./Navigation";
 import ActionBar from "./ActionBar";
 import notifier from "../app/Notifier";
 import Preferences from "./Preferences";
-import { useLiveQuery } from "dexie-react-hooks";
 import subscriptionManager from "../app/SubscriptionManager";
 import userManager from "../app/UserManager";
-import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
 import { expandUrl } from "../app/utils";
 import ErrorBoundary from "./ErrorBoundary";
 import routes from "./routes";
@@ -21,7 +22,6 @@ import { useAccountListener, useBackgroundProcesses, useConnectionListeners } fr
 import PublishDialog from "./PublishDialog";
 import Messaging from "./Messaging";
 import "./i18n"; // Translations!
-import { Backdrop, CircularProgress } from "@mui/material";
 import Login from "./Login";
 import Signup from "./Signup";
 import Account from "./Account";
@@ -66,12 +66,11 @@ const Layout = () => {
   const subscriptions = useLiveQuery(() => subscriptionManager.all());
   const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
   const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
-  const [selected] = (subscriptionsWithoutInternal || []).filter((s) => {
-    return (
+  const [selected] = (subscriptionsWithoutInternal || []).filter(
+    (s) =>
       (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) ||
       (config.base_url === s.baseUrl && params.topic === s.topic)
-    );
-  });
+  );
 
   useConnectionListeners(account, subscriptions, users);
   useAccountListener(setAccount);
@@ -95,7 +94,7 @@ const Layout = () => {
         <Outlet
           context={{
             subscriptions: subscriptionsWithoutInternal,
-            selected: selected,
+            selected,
           }}
         />
       </Main>
@@ -104,30 +103,28 @@ const Layout = () => {
   );
 };
 
-const Main = (props) => {
-  return (
-    <Box
-      id="main"
-      component="main"
-      sx={{
-        display: "flex",
-        flexGrow: 1,
-        flexDirection: "column",
-        padding: 3,
-        width: { sm: `calc(100% - ${Navigation.width}px)` },
-        height: "100vh",
-        overflow: "auto",
-        backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]),
-      }}
-    >
-      {props.children}
-    </Box>
-  );
-};
+const Main = (props) => (
+  <Box
+    id="main"
+    component="main"
+    sx={{
+      display: "flex",
+      flexGrow: 1,
+      flexDirection: "column",
+      padding: 3,
+      width: { sm: `calc(100% - ${Navigation.width}px)` },
+      height: "100vh",
+      overflow: "auto",
+      backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]),
+    }}
+  >
+    {props.children}
+  </Box>
+);
 
 const Loader = () => (
   <Backdrop
-    open={true}
+    open
     sx={{
       zIndex: 100000,
       backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]),

+ 4 - 3
web/src/components/AttachmentIcon.jsx

@@ -1,16 +1,17 @@
 import * as React from "react";
 import Box from "@mui/material/Box";
+import { useTranslation } from "react-i18next";
 import fileDocument from "../img/file-document.svg";
 import fileImage from "../img/file-image.svg";
 import fileVideo from "../img/file-video.svg";
 import fileAudio from "../img/file-audio.svg";
 import fileApp from "../img/file-app.svg";
-import { useTranslation } from "react-i18next";
 
 const AttachmentIcon = (props) => {
   const { t } = useTranslation();
-  const type = props.type;
-  let imageFile, imageLabel;
+  const { type } = props;
+  let imageFile;
+  let imageLabel;
   if (!type) {
     imageFile = fileDocument;
     imageLabel = t("notifications_attachment_file_image");

+ 16 - 18
web/src/components/AvatarBox.jsx

@@ -3,23 +3,21 @@ import { Avatar } from "@mui/material";
 import Box from "@mui/material/Box";
 import logo from "../img/ntfy-filled.svg";
 
-const AvatarBox = (props) => {
-  return (
-    <Box
-      sx={{
-        display: "flex",
-        flexGrow: 1,
-        justifyContent: "center",
-        flexDirection: "column",
-        alignContent: "center",
-        alignItems: "center",
-        height: "100vh",
-      }}
-    >
-      <Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />
-      {props.children}
-    </Box>
-  );
-};
+const AvatarBox = (props) => (
+  <Box
+    sx={{
+      display: "flex",
+      flexGrow: 1,
+      justifyContent: "center",
+      flexDirection: "column",
+      alignContent: "center",
+      alignItems: "center",
+      height: "100vh",
+    }}
+  >
+    <Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />
+    {props.children}
+  </Box>
+);
 
 export default AvatarBox;

+ 21 - 23
web/src/components/DialogFooter.jsx

@@ -3,31 +3,29 @@ import Box from "@mui/material/Box";
 import DialogContentText from "@mui/material/DialogContentText";
 import DialogActions from "@mui/material/DialogActions";
 
-const DialogFooter = (props) => {
-  return (
-    <Box
+const DialogFooter = (props) => (
+  <Box
+    sx={{
+      display: "flex",
+      flexDirection: "row",
+      justifyContent: "space-between",
+      paddingLeft: "24px",
+      paddingBottom: "8px",
+    }}
+  >
+    <DialogContentText
+      component="div"
+      aria-live="polite"
       sx={{
-        display: "flex",
-        flexDirection: "row",
-        justifyContent: "space-between",
-        paddingLeft: "24px",
-        paddingBottom: "8px",
+        margin: "0px",
+        paddingTop: "12px",
+        paddingBottom: "4px",
       }}
     >
-      <DialogContentText
-        component="div"
-        aria-live="polite"
-        sx={{
-          margin: "0px",
-          paddingTop: "12px",
-          paddingBottom: "4px",
-        }}
-      >
-        {props.status}
-      </DialogContentText>
-      <DialogActions sx={{ paddingRight: 2 }}>{props.children}</DialogActions>
-    </Box>
-  );
-};
+      {props.status}
+    </DialogContentText>
+    <DialogActions sx={{ paddingRight: 2 }}>{props.children}</DialogActions>
+  </Box>
+);
 
 export default DialogFooter;

+ 4 - 4
web/src/components/EmojiPicker.jsx

@@ -1,15 +1,15 @@
 import * as React from "react";
 import { useRef, useState } from "react";
 import Typography from "@mui/material/Typography";
-import { rawEmojis } from "../app/emojis";
 import Box from "@mui/material/Box";
 import TextField from "@mui/material/TextField";
 import { ClickAwayListener, Fade, InputAdornment, styled } from "@mui/material";
 import IconButton from "@mui/material/IconButton";
 import { Close } from "@mui/icons-material";
 import Popper from "@mui/material/Popper";
-import { splitNoEmpty } from "../app/utils";
 import { useTranslation } from "react-i18next";
+import { splitNoEmpty } from "../app/utils";
+import { rawEmojis } from "../app/emojis";
 
 // Create emoji list by category and create a search base (string with all search words)
 //
@@ -28,7 +28,7 @@ rawEmojis.forEach((emoji) => {
     const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
     if (supportedEmoji) {
       const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`;
-      const emojiWithSearchBase = { ...emoji, searchBase: searchBase };
+      const emojiWithSearchBase = { ...emoji, searchBase };
       emojisByCategory[emoji.category].push(emojiWithSearchBase);
     }
   } catch (e) {
@@ -133,7 +133,7 @@ const Category = (props) => {
 };
 
 const Emoji = (props) => {
-  const emoji = props.emoji;
+  const { emoji } = props;
   const matches = emojiMatches(emoji, props.search);
   const title = `${emoji.description} (${emoji.aliases[0]})`;
   return (

+ 4 - 5
web/src/components/ErrorBoundary.jsx

@@ -46,9 +46,9 @@ class ErrorBoundaryImpl extends React.Component {
     // Fetch additional info and a better stack trace
     StackTrace.fromError(error).then((stack) => {
       console.error("[ErrorBoundary] Stacktrace fetched", stack);
-      const niceStack =
-        `${error.toString()}\n` +
-        stack.map((el) => `  at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
+      const niceStack = `${error.toString()}\n${stack
+        .map((el) => `  at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`)
+        .join("\n")}`;
       this.setState({ niceStack });
     });
   }
@@ -73,9 +73,8 @@ class ErrorBoundaryImpl extends React.Component {
     if (this.state.error) {
       if (this.state.unsupportedIndexedDB) {
         return this.renderUnsupportedIndexedDB();
-      } else {
-        return this.renderError();
       }
+      return this.renderError();
     }
     return this.props.children;
   }

+ 4 - 4
web/src/components/Login.jsx

@@ -5,15 +5,15 @@ import WarningAmberIcon from "@mui/icons-material/WarningAmber";
 import TextField from "@mui/material/TextField";
 import Button from "@mui/material/Button";
 import Box from "@mui/material/Box";
-import routes from "./routes";
-import session from "../app/Session";
 import { NavLink } from "react-router-dom";
-import AvatarBox from "./AvatarBox";
 import { useTranslation } from "react-i18next";
-import accountApi from "../app/AccountApi";
 import IconButton from "@mui/material/IconButton";
 import { InputAdornment } from "@mui/material";
 import { Visibility, VisibilityOff } from "@mui/icons-material";
+import accountApi from "../app/AccountApi";
+import AvatarBox from "./AvatarBox";
+import session from "../app/Session";
+import routes from "./routes";
 import { UnauthorizedError } from "../app/errors";
 
 const Login = () => {

+ 6 - 6
web/src/components/Messaging.jsx

@@ -1,21 +1,21 @@
 import * as React from "react";
 import { useState } from "react";
-import Navigation from "./Navigation";
 import Paper from "@mui/material/Paper";
 import IconButton from "@mui/material/IconButton";
 import TextField from "@mui/material/TextField";
 import SendIcon from "@mui/icons-material/Send";
-import api from "../app/Api";
-import PublishDialog from "./PublishDialog";
 import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
 import { Portal, Snackbar } from "@mui/material";
 import { useTranslation } from "react-i18next";
+import PublishDialog from "./PublishDialog";
+import api from "../app/Api";
+import Navigation from "./Navigation";
 
 const Messaging = (props) => {
   const [message, setMessage] = useState("");
   const [dialogKey, setDialogKey] = useState(0);
 
-  const dialogOpenMode = props.dialogOpenMode;
+  const { dialogOpenMode } = props;
   const subscription = props.selected;
 
   const handleOpenDialogClick = () => {
@@ -39,7 +39,7 @@ const Messaging = (props) => {
         topic={subscription?.topic ?? ""}
         message={message}
         onClose={handleDialogClose}
-        onDragEnter={() => props.onDialogOpenModeChange((prev) => (prev ? prev : PublishDialog.OPEN_MODE_DRAG))} // Only update if not already open
+        onDragEnter={() => props.onDialogOpenModeChange((prev) => prev || PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open
         onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}
       />
     </>
@@ -48,7 +48,7 @@ const Messaging = (props) => {
 
 const MessageBar = (props) => {
   const { t } = useTranslation();
-  const subscription = props.subscription;
+  const { subscription } = props;
   const [snackOpen, setSnackOpen] = useState(false);
   const handleSendClick = async () => {
     try {

+ 10 - 12
web/src/components/Navigation.jsx

@@ -11,28 +11,28 @@ import Divider from "@mui/material/Divider";
 import List from "@mui/material/List";
 import SettingsIcon from "@mui/icons-material/Settings";
 import AddIcon from "@mui/icons-material/Add";
-import SubscribeDialog from "./SubscribeDialog";
 import { Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Portal, Tooltip } from "@mui/material";
 import Button from "@mui/material/Button";
 import Typography from "@mui/material/Typography";
+import { useLocation, useNavigate } from "react-router-dom";
+import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
+import Box from "@mui/material/Box";
+import ArticleIcon from "@mui/icons-material/Article";
+import { Trans, useTranslation } from "react-i18next";
+import CelebrationIcon from "@mui/icons-material/Celebration";
+import IconButton from "@mui/material/IconButton";
+import SubscribeDialog from "./SubscribeDialog";
 import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
 import routes from "./routes";
 import { ConnectionState } from "../app/Connection";
-import { useLocation, useNavigate } from "react-router-dom";
 import subscriptionManager from "../app/SubscriptionManager";
-import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
-import Box from "@mui/material/Box";
 import notifier from "../app/Notifier";
 import config from "../app/config";
-import ArticleIcon from "@mui/icons-material/Article";
-import { Trans, useTranslation } from "react-i18next";
 import session from "../app/Session";
 import accountApi, { Permission, Role } from "../app/AccountApi";
-import CelebrationIcon from "@mui/icons-material/Celebration";
 import UpgradeDialog from "./UpgradeDialog";
 import { AccountContext } from "./App";
 import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
-import IconButton from "@mui/material/IconButton";
 import { SubscriptionPopup } from "./SubscriptionPopup";
 
 const navWidth = 280;
@@ -237,9 +237,7 @@ const UpgradeBanner = () => {
 const SubscriptionList = (props) => {
   const sortedSubscriptions = props.subscriptions
     .filter((s) => !s.internal)
-    .sort((a, b) => {
-      return topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1;
-    });
+    .sort((a, b) => (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1));
   return (
     <>
       {sortedSubscriptions.map((subscription) => (
@@ -258,7 +256,7 @@ const SubscriptionItem = (props) => {
   const navigate = useNavigate();
   const [menuAnchorEl, setMenuAnchorEl] = useState(null);
 
-  const subscription = props.subscription;
+  const { subscription } = props;
   const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
   const displayName = topicDisplayName(subscription);
   const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName;

+ 52 - 50
web/src/components/Notifications.jsx

@@ -4,6 +4,15 @@ import Card from "@mui/material/Card";
 import Typography from "@mui/material/Typography";
 import * as React from "react";
 import { useEffect, useState } from "react";
+import IconButton from "@mui/material/IconButton";
+import CheckIcon from "@mui/icons-material/Check";
+import CloseIcon from "@mui/icons-material/Close";
+import { useLiveQuery } from "dexie-react-hooks";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import InfiniteScroll from "react-infinite-scroll-component";
+import { Trans, useTranslation } from "react-i18next";
+import { useOutletContext } from "react-router-dom";
 import {
   formatBytes,
   formatMessage,
@@ -15,23 +24,14 @@ import {
   topicShortUrl,
   unmatchedTags,
 } from "../app/utils";
-import IconButton from "@mui/material/IconButton";
-import CheckIcon from "@mui/icons-material/Check";
-import CloseIcon from "@mui/icons-material/Close";
 import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
-import { useLiveQuery } from "dexie-react-hooks";
-import Box from "@mui/material/Box";
-import Button from "@mui/material/Button";
 import subscriptionManager from "../app/SubscriptionManager";
-import InfiniteScroll from "react-infinite-scroll-component";
 import priority1 from "../img/priority-1.svg";
 import priority2 from "../img/priority-2.svg";
 import priority4 from "../img/priority-4.svg";
 import priority5 from "../img/priority-5.svg";
 import logoOutline from "../img/ntfy-outline.svg";
 import AttachmentIcon from "./AttachmentIcon";
-import { Trans, useTranslation } from "react-i18next";
-import { useOutletContext } from "react-router-dom";
 import { useAutoSubscribe } from "./hooks";
 
 export const AllSubscriptions = () => {
@@ -52,46 +52,50 @@ export const SingleSubscription = () => {
 };
 
 const AllSubscriptionsList = (props) => {
-  const subscriptions = props.subscriptions;
+  const { subscriptions } = props;
   const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
   if (notifications === null || notifications === undefined) {
     return <Loading />;
-  } else if (subscriptions.length === 0) {
+  }
+  if (subscriptions.length === 0) {
     return <NoSubscriptions />;
-  } else if (notifications.length === 0) {
+  }
+  if (notifications.length === 0) {
     return <NoNotificationsWithoutSubscription subscriptions={subscriptions} />;
   }
   return <NotificationList key="all" notifications={notifications} messageBar={false} />;
 };
 
 const SingleSubscriptionList = (props) => {
-  const subscription = props.subscription;
+  const { subscription } = props;
   const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
   if (notifications === null || notifications === undefined) {
     return <Loading />;
-  } else if (notifications.length === 0) {
+  }
+  if (notifications.length === 0) {
     return <NoNotifications subscription={subscription} />;
   }
-  return <NotificationList id={subscription.id} notifications={notifications} messageBar={true} />;
+  return <NotificationList id={subscription.id} notifications={notifications} messageBar />;
 };
 
 const NotificationList = (props) => {
   const { t } = useTranslation();
   const pageSize = 20;
-  const notifications = props.notifications;
+  const { notifications } = props;
   const [snackOpen, setSnackOpen] = useState(false);
   const [maxCount, setMaxCount] = useState(pageSize);
   const count = Math.min(notifications.length, maxCount);
 
-  useEffect(() => {
-    return () => {
+  useEffect(
+    () => () => {
       setMaxCount(pageSize);
       const main = document.getElementById("main");
       if (main) {
         main.scrollTo(0, 0);
       }
-    };
-  }, [props.id]);
+    },
+    [props.id]
+  );
 
   return (
     <InfiniteScroll
@@ -129,8 +133,8 @@ const NotificationList = (props) => {
 
 const NotificationItem = (props) => {
   const { t } = useTranslation();
-  const notification = props.notification;
-  const attachment = notification.attachment;
+  const { notification } = props;
+  const { attachment } = notification;
   const date = formatShortDateTime(notification.time);
   const otherTags = unmatchedTags(notification.tags);
   const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
@@ -272,7 +276,7 @@ const priorityFiles = {
 
 const Attachment = (props) => {
   const { t } = useTranslation();
-  const attachment = props.attachment;
+  const { attachment } = props;
   const expired = attachment.expires && attachment.expires < Date.now() / 1000;
   const expires = attachment.expires && attachment.expires > Date.now() / 1000;
   const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
@@ -402,20 +406,18 @@ const Image = (props) => {
   );
 };
 
-const UserActions = (props) => {
-  return (
-    <>
-      {props.notification.actions.map((action) => (
-        <UserAction key={action.id} notification={props.notification} action={action} />
-      ))}
-    </>
-  );
-};
+const UserActions = (props) => (
+  <>
+    {props.notification.actions.map((action) => (
+      <UserAction key={action.id} notification={props.notification} action={action} />
+    ))}
+  </>
+);
 
 const UserAction = (props) => {
   const { t } = useTranslation();
-  const notification = props.notification;
-  const action = props.action;
+  const { notification } = props;
+  const { action } = props;
   if (action.action === "broadcast") {
     return (
       <Tooltip title={t("notifications_actions_not_supported")}>
@@ -426,7 +428,8 @@ const UserAction = (props) => {
         </span>
       </Tooltip>
     );
-  } else if (action.action === "view") {
+  }
+  if (action.action === "view") {
     return (
       <Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
         <Button
@@ -439,20 +442,21 @@ const UserAction = (props) => {
         </Button>
       </Tooltip>
     );
-  } else if (action.action === "http") {
+  }
+  if (action.action === "http") {
     const method = action.method ?? "POST";
     const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
     return (
       <Tooltip
         title={t("notifications_actions_http_request_title", {
-          method: method,
+          method,
           url: action.url,
         })}
       >
         <Button
           onClick={() => performHttpAction(notification, action)}
           aria-label={t("notifications_actions_http_request_title", {
-            method: method,
+            method,
             url: action.url,
           })}
         >
@@ -493,7 +497,7 @@ const updateActionStatus = (notification, action, progress, error) => {
     if (a.id !== action.id) {
       return a;
     }
-    return { ...a, progress: progress, error: error };
+    return { ...a, progress, error };
   });
   subscriptionManager.updateNotification(notification);
 };
@@ -574,17 +578,15 @@ const NoSubscriptions = () => {
   );
 };
 
-const ForMoreDetails = () => {
-  return (
-    <Trans
-      i18nKey="notifications_more_details"
-      components={{
-        websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener" />,
-        docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />,
-      }}
-    />
-  );
-};
+const ForMoreDetails = () => (
+  <Trans
+    i18nKey="notifications_more_details"
+    components={{
+      websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener" />,
+      docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />,
+    }}
+  />
+);
 
 const Loading = () => {
   const { t } = useTranslation();

+ 2 - 2
web/src/components/PopupMenu.jsx

@@ -37,8 +37,8 @@ const PopupMenu = (props) => {
           },
         },
       }}
-      transformOrigin={{ horizontal: horizontal, vertical: "top" }}
-      anchorOrigin={{ horizontal: horizontal, vertical: "bottom" }}
+      transformOrigin={{ horizontal, vertical: "top" }}
+      anchorOrigin={{ horizontal, vertical: "bottom" }}
     >
       {props.children}
     </Menu>

+ 3 - 5
web/src/components/Pref.jsx

@@ -1,8 +1,6 @@
 import * as React from "react";
 
-export const PrefGroup = (props) => {
-  return <div role="table">{props.children}</div>;
-};
+export const PrefGroup = (props) => <div role="table">{props.children}</div>;
 
 export const Pref = (props) => {
   const justifyContent = props.alignTop ? "normal" : "center";
@@ -24,7 +22,7 @@ export const Pref = (props) => {
           flex: "1 0 40%",
           display: "flex",
           flexDirection: "column",
-          justifyContent: justifyContent,
+          justifyContent,
           paddingRight: "30px",
         }}
       >
@@ -44,7 +42,7 @@ export const Pref = (props) => {
           flex: "1 0 calc(60% - 50px)",
           display: "flex",
           flexDirection: "column",
-          justifyContent: justifyContent,
+          justifyContent,
         }}
       >
         {props.children}

+ 22 - 24
web/src/components/Preferences.jsx

@@ -17,8 +17,6 @@ import {
   useMediaQuery,
 } from "@mui/material";
 import Typography from "@mui/material/Typography";
-import prefs from "../app/Prefs";
-import { Paragraph } from "./styles";
 import EditIcon from "@mui/icons-material/Edit";
 import CloseIcon from "@mui/icons-material/Close";
 import IconButton from "@mui/material/IconButton";
@@ -29,39 +27,39 @@ import MenuItem from "@mui/material/MenuItem";
 import Card from "@mui/material/Card";
 import Button from "@mui/material/Button";
 import { useLiveQuery } from "dexie-react-hooks";
-import theme from "./theme";
 import Dialog from "@mui/material/Dialog";
 import DialogTitle from "@mui/material/DialogTitle";
 import DialogContent from "@mui/material/DialogContent";
 import DialogActions from "@mui/material/DialogActions";
+import { useTranslation } from "react-i18next";
+import { Info } from "@mui/icons-material";
+import { useOutletContext } from "react-router-dom";
+import theme from "./theme";
 import userManager from "../app/UserManager";
 import { playSound, shuffle, sounds, validUrl } from "../app/utils";
-import { useTranslation } from "react-i18next";
 import session from "../app/Session";
 import routes from "./routes";
 import accountApi, { Permission, Role } from "../app/AccountApi";
 import { Pref, PrefGroup } from "./Pref";
-import { Info } from "@mui/icons-material";
 import { AccountContext } from "./App";
-import { useOutletContext } from "react-router-dom";
+import { Paragraph } from "./styles";
+import prefs from "../app/Prefs";
 import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
 import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
 import { UnauthorizedError } from "../app/errors";
 import subscriptionManager from "../app/SubscriptionManager";
 import { subscribeTopic } from "./SubscribeDialog";
 
-const Preferences = () => {
-  return (
-    <Container maxWidth="md" sx={{ marginTop: 3, marginBottom: 3 }}>
-      <Stack spacing={3}>
-        <Notifications />
-        <Reservations />
-        <Users />
-        <Appearance />
-      </Stack>
-    </Container>
-  );
-};
+const Preferences = () => (
+  <Container maxWidth="md" sx={{ marginTop: 3, marginBottom: 3 }}>
+    <Stack spacing={3}>
+      <Notifications />
+      <Reservations />
+      <Users />
+      <Appearance />
+    </Stack>
+  </Container>
+);
 
 const Notifications = () => {
   const { t } = useTranslation();
@@ -107,7 +105,7 @@ const Sound = () => {
       <div style={{ display: "flex", width: "100%" }}>
         <FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
           <Select value={sound} onChange={handleChange} aria-labelledby={labelId}>
-            <MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem>
+            <MenuItem value="none">{t("prefs_notifications_sound_no_sound")}</MenuItem>
             {Object.entries(sounds).map((s) => (
               <MenuItem key={s[0]} value={s[0]}>
                 {s[1].label}
@@ -245,7 +243,7 @@ const Users = () => {
         </Typography>
         <Paragraph>
           {t("prefs_users_description")}
-          {session.exists() && <>{" " + t("prefs_users_description_no_sync")}</>}
+          {session.exists() && <>{` ${t("prefs_users_description_no_sync")}`}</>}
         </Paragraph>
         {users?.length > 0 && <UserTable users={users} />}
       </CardContent>
@@ -371,9 +369,9 @@ const UserDialog = (props) => {
   })();
   const handleSubmit = async () => {
     props.onSubmit({
-      baseUrl: baseUrl,
-      username: username,
-      password: password,
+      baseUrl,
+      username,
+      password,
     });
   };
   useEffect(() => {
@@ -479,7 +477,7 @@ const Language = () => {
   const showFlags = !navigator.userAgent.includes("Windows");
   let title = t("prefs_appearance_language_title");
   if (showFlags) {
-    title += " " + randomFlags.join(" ");
+    title += ` ${randomFlags.join(" ")}`;
   }
 
   const handleChange = async (ev) => {

+ 23 - 29
web/src/components/PublishDialog.jsx

@@ -1,13 +1,7 @@
 import * as React from "react";
 import { useContext, useEffect, useRef, useState } from "react";
-import theme from "./theme";
 import { Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, Tooltip, useMediaQuery } from "@mui/material";
 import TextField from "@mui/material/TextField";
-import priority1 from "../img/priority-1.svg";
-import priority2 from "../img/priority-2.svg";
-import priority3 from "../img/priority-3.svg";
-import priority4 from "../img/priority-4.svg";
-import priority5 from "../img/priority-5.svg";
 import Dialog from "@mui/material/Dialog";
 import DialogTitle from "@mui/material/DialogTitle";
 import DialogContent from "@mui/material/DialogContent";
@@ -17,14 +11,20 @@ import IconButton from "@mui/material/IconButton";
 import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
 import { Close } from "@mui/icons-material";
 import MenuItem from "@mui/material/MenuItem";
-import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils";
 import Box from "@mui/material/Box";
+import { Trans, useTranslation } from "react-i18next";
+import priority1 from "../img/priority-1.svg";
+import priority2 from "../img/priority-2.svg";
+import priority3 from "../img/priority-3.svg";
+import priority4 from "../img/priority-4.svg";
+import priority5 from "../img/priority-5.svg";
+import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils";
 import AttachmentIcon from "./AttachmentIcon";
 import DialogFooter from "./DialogFooter";
 import api from "../app/Api";
 import userManager from "../app/UserManager";
 import EmojiPicker from "./EmojiPicker";
-import { Trans, useTranslation } from "react-i18next";
+import theme from "./theme";
 import session from "../app/Session";
 import routes from "./routes";
 import accountApi from "../app/AccountApi";
@@ -137,7 +137,7 @@ const PublishDialog = (props) => {
     if (attachFile && message.trim()) {
       url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
     }
-    const body = attachFile ? attachFile : message;
+    const body = attachFile || message;
     try {
       const user = await userManager.get(baseUrl);
       const headers = maybeWithAuth({}, user);
@@ -183,13 +183,15 @@ const PublishDialog = (props) => {
             remainingBytes: formatBytes(remainingBytes),
           })
         );
-      } else if (fileSizeLimitReached) {
+      }
+      if (fileSizeLimitReached) {
         return setAttachFileError(
           t("publish_dialog_attachment_limits_file_reached", {
             fileSizeLimit: formatBytes(fileSizeLimit),
           })
         );
-      } else if (quotaReached) {
+      }
+      if (quotaReached) {
         return setAttachFileError(
           t("publish_dialog_attachment_limits_quota_reached", {
             remainingBytes: formatBytes(remainingBytes),
@@ -377,7 +379,7 @@ const PublishDialog = (props) => {
                     key={`priorityMenuItem${priority}`}
                     value={priority}
                     aria-label={t("notifications_priority_x", {
-                      priority: priority,
+                      priority,
                     })}
                   >
                     <div style={{ display: "flex", alignItems: "center" }}>
@@ -385,7 +387,7 @@ const PublishDialog = (props) => {
                         src={priorities[priority].file}
                         style={{ marginRight: "8px" }}
                         alt={t("notifications_priority_x", {
-                          priority: priority,
+                          priority,
                         })}
                       />
                       <div>{priorities[priority].label}</div>
@@ -533,7 +535,7 @@ const PublishDialog = (props) => {
               />
             </ClosableRow>
           )}
-          <input type="file" ref={attachFileInput} onChange={handleAttachFileChanged} style={{ display: "none" }} aria-hidden={true} />
+          <input type="file" ref={attachFileInput} onChange={handleAttachFileChanged} style={{ display: "none" }} aria-hidden />
           {showAttachFile && (
             <AttachmentBox
               file={attachFile}
@@ -707,13 +709,11 @@ const PublishDialog = (props) => {
   );
 };
 
-const Row = (props) => {
-  return (
-    <div style={{ display: "flex" }} role="row">
-      {props.children}
-    </div>
-  );
-};
+const Row = (props) => (
+  <div style={{ display: "flex" }} role="row">
+    {props.children}
+  </div>
+);
 
 const ClosableRow = (props) => {
   const closable = props.hasOwnProperty("closable") ? props.closable : true;
@@ -748,7 +748,7 @@ const DialogIconButton = (props) => {
 
 const AttachmentBox = (props) => {
   const { t } = useTranslation();
-  const file = props.file;
+  const { file } = props;
   return (
     <>
       <Typography variant="body1" sx={{ marginTop: 2 }}>
@@ -811,13 +811,7 @@ const ExpandingTextField = (props) => {
   }, [props.value]);
   return (
     <>
-      <Typography
-        ref={invisibleFieldRef}
-        component="span"
-        variant={props.variant}
-        aria-hidden={true}
-        sx={{ position: "absolute", left: "-200%" }}
-      >
+      <Typography ref={invisibleFieldRef} component="span" variant={props.variant} aria-hidden sx={{ position: "absolute", left: "-200%" }}>
         {props.value}
       </Typography>
       <TextField

+ 6 - 6
web/src/components/ReserveDialogs.jsx

@@ -7,18 +7,18 @@ import DialogContent from "@mui/material/DialogContent";
 import DialogContentText from "@mui/material/DialogContentText";
 import DialogTitle from "@mui/material/DialogTitle";
 import { Alert, FormControl, Select, useMediaQuery } from "@mui/material";
+import { useTranslation } from "react-i18next";
+import MenuItem from "@mui/material/MenuItem";
+import ListItemIcon from "@mui/material/ListItemIcon";
+import ListItemText from "@mui/material/ListItemText";
+import { Check, DeleteForever } from "@mui/icons-material";
 import theme from "./theme";
 import { validTopic } from "../app/utils";
 import DialogFooter from "./DialogFooter";
-import { useTranslation } from "react-i18next";
 import session from "../app/Session";
 import routes from "./routes";
 import accountApi, { Permission } from "../app/AccountApi";
 import ReserveTopicSelect from "./ReserveTopicSelect";
-import MenuItem from "@mui/material/MenuItem";
-import ListItemIcon from "@mui/material/ListItemIcon";
-import ListItemText from "@mui/material/ListItemText";
-import { Check, DeleteForever } from "@mui/icons-material";
 import { TopicReservedError, UnauthorizedError } from "../app/errors";
 
 export const ReserveAddDialog = (props) => {
@@ -164,7 +164,7 @@ export const ReserveDeleteDialog = (props) => {
               </ListItemIcon>
               <ListItemText primary={t("reservation_delete_dialog_action_keep_title")} />
             </MenuItem>
-            <MenuItem value={true}>
+            <MenuItem value>
               <ListItemIcon>
                 <DeleteForever />
               </ListItemIcon>

+ 4 - 12
web/src/components/ReserveIcons.jsx

@@ -2,21 +2,13 @@ import * as React from "react";
 import { Lock, Public } from "@mui/icons-material";
 import Box from "@mui/material/Box";
 
-export const PermissionReadWrite = React.forwardRef((props, ref) => {
-  return <PermissionInternal icon={Public} ref={ref} {...props} />;
-});
+export const PermissionReadWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} ref={ref} {...props} />);
 
-export const PermissionDenyAll = React.forwardRef((props, ref) => {
-  return <PermissionInternal icon={Lock} ref={ref} {...props} />;
-});
+export const PermissionDenyAll = React.forwardRef((props, ref) => <PermissionInternal icon={Lock} ref={ref} {...props} />);
 
-export const PermissionRead = React.forwardRef((props, ref) => {
-  return <PermissionInternal icon={Public} text="R" ref={ref} {...props} />;
-});
+export const PermissionRead = React.forwardRef((props, ref) => <PermissionInternal icon={Public} text="R" ref={ref} {...props} />);
 
-export const PermissionWrite = React.forwardRef((props, ref) => {
-  return <PermissionInternal icon={Public} text="W" ref={ref} {...props} />;
-});
+export const PermissionWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} text="W" ref={ref} {...props} />);
 
 const PermissionInternal = React.forwardRef((props, ref) => {
   const size = props.size ?? "medium";

+ 4 - 4
web/src/components/Signup.jsx

@@ -3,17 +3,17 @@ import { useState } from "react";
 import TextField from "@mui/material/TextField";
 import Button from "@mui/material/Button";
 import Box from "@mui/material/Box";
-import routes from "./routes";
-import session from "../app/Session";
 import Typography from "@mui/material/Typography";
 import { NavLink } from "react-router-dom";
-import AvatarBox from "./AvatarBox";
 import { useTranslation } from "react-i18next";
 import WarningAmberIcon from "@mui/icons-material/WarningAmber";
-import accountApi from "../app/AccountApi";
 import { InputAdornment } from "@mui/material";
 import IconButton from "@mui/material/IconButton";
 import { Visibility, VisibilityOff } from "@mui/icons-material";
+import accountApi from "../app/AccountApi";
+import AvatarBox from "./AvatarBox";
+import session from "../app/Session";
+import routes from "./routes";
 import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors";
 
 const Signup = () => {

+ 10 - 12
web/src/components/SubscribeDialog.jsx

@@ -7,6 +7,7 @@ import DialogContent from "@mui/material/DialogContent";
 import DialogContentText from "@mui/material/DialogContentText";
 import DialogTitle from "@mui/material/DialogTitle";
 import { Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery } from "@mui/material";
+import { useTranslation } from "react-i18next";
 import theme from "./theme";
 import api from "../app/Api";
 import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils";
@@ -14,7 +15,6 @@ import userManager from "../app/UserManager";
 import subscriptionManager from "../app/SubscriptionManager";
 import poller from "../app/Poller";
 import DialogFooter from "./DialogFooter";
-import { useTranslation } from "react-i18next";
 import session from "../app/Session";
 import routes from "./routes";
 import accountApi, { Permission, Role } from "../app/AccountApi";
@@ -33,7 +33,7 @@ const SubscribeDialog = (props) => {
 
   const handleSuccess = async () => {
     console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
-    const actualBaseUrl = baseUrl ? baseUrl : config.base_url;
+    const actualBaseUrl = baseUrl || config.base_url;
     const subscription = await subscribeTopic(actualBaseUrl, topic);
     poller.pollInBackground(subscription); // Dangle!
     props.onSuccess(subscription);
@@ -66,7 +66,7 @@ const SubscribePage = (props) => {
   const [anotherServerVisible, setAnotherServerVisible] = useState(false);
   const [everyone, setEveryone] = useState(Permission.DENY_ALL);
   const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url;
-  const topic = props.topic;
+  const { topic } = props;
   const existingTopicUrls = props.subscriptions.map((s) => topicUrl(s.baseUrl, s.topic));
   const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])).filter(
     (s) => s !== config.base_url
@@ -86,14 +86,13 @@ const SubscribePage = (props) => {
       if (user) {
         setError(
           t("subscribe_dialog_error_user_not_authorized", {
-            username: username,
+            username,
           })
         );
         return;
-      } else {
-        props.onNeedsLogin();
-        return;
       }
+      props.onNeedsLogin();
+      return;
     }
 
     // Reserve topic (if requested)
@@ -125,10 +124,9 @@ const SubscribePage = (props) => {
     if (anotherServerVisible) {
       const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
       return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
-    } else {
-      const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic));
-      return validTopic(topic) && !isExistingTopicUrl;
     }
+    const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic));
+    return validTopic(topic) && !isExistingTopicUrl;
   })();
 
   const updateBaseUrl = (ev, newVal) => {
@@ -242,14 +240,14 @@ const LoginPage = (props) => {
   const [password, setPassword] = useState("");
   const [error, setError] = useState("");
   const baseUrl = props.baseUrl ? props.baseUrl : config.base_url;
-  const topic = props.topic;
+  const { topic } = props;
 
   const handleLogin = async () => {
     const user = { baseUrl, username, password };
     const success = await api.topicAuth(baseUrl, topic, user);
     if (!success) {
       console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
-      setError(t("subscribe_dialog_error_user_not_authorized", { username: username }));
+      setError(t("subscribe_dialog_error_user_not_authorized", { username }));
       return;
     }
     console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);

+ 17 - 15
web/src/components/SubscriptionPopup.jsx

@@ -7,20 +7,20 @@ import DialogContent from "@mui/material/DialogContent";
 import DialogContentText from "@mui/material/DialogContentText";
 import DialogTitle from "@mui/material/DialogTitle";
 import { Chip, InputAdornment, Portal, Snackbar, useMediaQuery } from "@mui/material";
+import { useTranslation } from "react-i18next";
+import MenuItem from "@mui/material/MenuItem";
+import { useNavigate } from "react-router-dom";
+import IconButton from "@mui/material/IconButton";
+import { Clear } from "@mui/icons-material";
 import theme from "./theme";
 import subscriptionManager from "../app/SubscriptionManager";
 import DialogFooter from "./DialogFooter";
-import { useTranslation } from "react-i18next";
 import accountApi, { Role } from "../app/AccountApi";
 import session from "../app/Session";
 import routes from "./routes";
-import MenuItem from "@mui/material/MenuItem";
 import PopupMenu from "./PopupMenu";
 import { formatShortDateTime, shuffle } from "../app/utils";
 import api from "../app/Api";
-import { useNavigate } from "react-router-dom";
-import IconButton from "@mui/material/IconButton";
-import { Clear } from "@mui/icons-material";
 import { AccountContext } from "./App";
 import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
 import { UnauthorizedError } from "../app/errors";
@@ -34,7 +34,7 @@ export const SubscriptionPopup = (props) => {
   const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false);
   const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false);
   const [showPublishError, setShowPublishError] = useState(false);
-  const subscription = props.subscription;
+  const { subscription } = props;
   const placement = props.placement ?? "left";
   const reservations = account?.reservations || [];
 
@@ -64,8 +64,8 @@ export const SubscriptionPopup = (props) => {
   };
 
   const handleSendTestMessage = async () => {
-    const baseUrl = props.subscription.baseUrl;
-    const topic = props.subscription.topic;
+    const { baseUrl } = props.subscription;
+    const { topic } = props.subscription;
     const tags = shuffle([
       "grinning",
       "octopus",
@@ -110,9 +110,9 @@ export const SubscriptionPopup = (props) => {
     ])[0];
     try {
       await api.publish(baseUrl, topic, message, {
-        title: title,
-        priority: priority,
-        tags: tags,
+        title,
+        priority,
+        tags,
       });
     } catch (e) {
       console.log(`[SubscriptionPopup] Error publishing message`, e);
@@ -201,7 +201,7 @@ export const SubscriptionPopup = (props) => {
 
 const DisplayNameDialog = (props) => {
   const { t } = useTranslation();
-  const subscription = props.subscription;
+  const { subscription } = props;
   const [error, setError] = useState("");
   const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
   const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
@@ -265,9 +265,11 @@ export const ReserveLimitChip = () => {
   const { account } = useContext(AccountContext);
   if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) {
     return <></>;
-  } else if (config.enable_payments) {
+  }
+  if (config.enable_payments) {
     return account?.limits.reservations > 0 ? <LimitReachedChip /> : <ProChip />;
-  } else if (account) {
+  }
+  if (account) {
     return <LimitReachedChip />;
   }
   return <></>;
@@ -294,7 +296,7 @@ export const ProChip = () => {
   const { t } = useTranslation();
   return (
     <Chip
-      label={"ntfy Pro"}
+      label="ntfy Pro"
       variant="outlined"
       color="primary"
       sx={{

+ 29 - 31
web/src/components/UpgradeDialog.jsx

@@ -4,15 +4,9 @@ import Dialog from "@mui/material/Dialog";
 import DialogContent from "@mui/material/DialogContent";
 import DialogTitle from "@mui/material/DialogTitle";
 import { Alert, CardActionArea, CardContent, Chip, Link, ListItem, Switch, useMediaQuery } from "@mui/material";
-import theme from "./theme";
 import Button from "@mui/material/Button";
-import accountApi, { SubscriptionInterval } from "../app/AccountApi";
-import session from "../app/Session";
-import routes from "./routes";
 import Card from "@mui/material/Card";
 import Typography from "@mui/material/Typography";
-import { AccountContext } from "./App";
-import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils";
 import { Trans, useTranslation } from "react-i18next";
 import List from "@mui/material/List";
 import { Check, Close } from "@mui/icons-material";
@@ -20,9 +14,15 @@ import ListItemIcon from "@mui/material/ListItemIcon";
 import ListItemText from "@mui/material/ListItemText";
 import Box from "@mui/material/Box";
 import { NavLink } from "react-router-dom";
-import { UnauthorizedError } from "../app/errors";
 import DialogContentText from "@mui/material/DialogContentText";
 import DialogActions from "@mui/material/DialogActions";
+import { UnauthorizedError } from "../app/errors";
+import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils";
+import { AccountContext } from "./App";
+import routes from "./routes";
+import session from "../app/Session";
+import accountApi, { SubscriptionInterval } from "../app/AccountApi";
+import theme from "./theme";
 
 const UpgradeDialog = (props) => {
   const { t } = useTranslation();
@@ -52,7 +52,9 @@ const UpgradeDialog = (props) => {
   const currentTierCode = currentTier?.code; // May be undefined
 
   // Figure out buttons, labels and the submit action
-  let submitAction, submitButtonLabel, banner;
+  let submitAction;
+  let submitButtonLabel;
+  let banner;
   if (!account) {
     submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
     submitAction = Action.REDIRECT_SIGNUP;
@@ -112,8 +114,8 @@ const UpgradeDialog = (props) => {
   };
 
   // Figure out discount
-  let discount = 0,
-    upto = false;
+  let discount = 0;
+  let upto = false;
   if (newTier?.prices) {
     discount = Math.round(((newTier.prices.month * 12) / newTier.prices.year - 1) * 100);
   } else {
@@ -157,8 +159,8 @@ const UpgradeDialog = (props) => {
               <Chip
                 label={
                   upto
-                    ? t("account_upgrade_dialog_interval_yearly_discount_save_up_to", { discount: discount })
-                    : t("account_upgrade_dialog_interval_yearly_discount_save", { discount: discount })
+                    ? t("account_upgrade_dialog_interval_yearly_discount_save_up_to", { discount })
+                    : t("account_upgrade_dialog_interval_yearly_discount_save", { discount })
                 }
                 color="primary"
                 size="small"
@@ -269,9 +271,11 @@ const UpgradeDialog = (props) => {
 
 const TierCard = (props) => {
   const { t } = useTranslation();
-  const tier = props.tier;
+  const { tier } = props;
 
-  let cardStyle, labelStyle, labelText;
+  let cardStyle;
+  let labelStyle;
+  let labelText;
   if (props.selected) {
     cardStyle = { background: "#eee", border: "3px solid #338574" };
     labelStyle = { background: "#338574", color: "white" };
@@ -392,25 +396,19 @@ const TierCard = (props) => {
   );
 };
 
-const Feature = (props) => {
-  return <FeatureItem feature={true}>{props.children}</FeatureItem>;
-};
+const Feature = (props) => <FeatureItem feature>{props.children}</FeatureItem>;
 
-const NoFeature = (props) => {
-  return <FeatureItem feature={false}>{props.children}</FeatureItem>;
-};
+const NoFeature = (props) => <FeatureItem feature={false}>{props.children}</FeatureItem>;
 
-const FeatureItem = (props) => {
-  return (
-    <ListItem disableGutters sx={{ m: 0, p: 0 }}>
-      <ListItemIcon sx={{ minWidth: "24px" }}>
-        {props.feature && <Check fontSize="small" sx={{ color: "#338574" }} />}
-        {!props.feature && <Close fontSize="small" sx={{ color: "gray" }} />}
-      </ListItemIcon>
-      <ListItemText sx={{ mt: "2px", mb: "2px" }} primary={<Typography variant="body1">{props.children}</Typography>} />
-    </ListItem>
-  );
-};
+const FeatureItem = (props) => (
+  <ListItem disableGutters sx={{ m: 0, p: 0 }}>
+    <ListItemIcon sx={{ minWidth: "24px" }}>
+      {props.feature && <Check fontSize="small" sx={{ color: "#338574" }} />}
+      {!props.feature && <Close fontSize="small" sx={{ color: "gray" }} />}
+    </ListItemIcon>
+    <ListItemText sx={{ mt: "2px", mb: "2px" }} primary={<Typography variant="body1">{props.children}</Typography>} />
+  </ListItem>
+);
 
 const Action = {
   REDIRECT_SIGNUP: 1,

+ 1 - 1
web/src/components/hooks.js

@@ -61,7 +61,7 @@ export const useConnectionListeners = (account, subscriptions, users) => {
       };
     },
     // We have to disable dep checking for "navigate". This is fine, it never changes.
-    // eslint-disable-next-line
+
     []
   );
 

+ 1 - 1
web/src/components/styles.js

@@ -1,7 +1,7 @@
 import Typography from "@mui/material/Typography";
-import theme from "./theme";
 import Container from "@mui/material/Container";
 import { Backdrop, styled } from "@mui/material";
+import theme from "./theme";
 
 export const Paragraph = styled(Typography)({
   paddingTop: 8,