diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index d3d5d4b6..9576c4ec 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -261,12 +261,12 @@ class AccountApi { async createBillingSubscription(tier, interval) { console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`); - return await this.upsertBillingSubscription("POST", tier, interval); + return this.upsertBillingSubscription("POST", tier, interval); } async updateBillingSubscription(tier, interval) { console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`); - return await this.upsertBillingSubscription("PUT", tier, interval); + return this.upsertBillingSubscription("PUT", tier, interval); } async upsertBillingSubscription(method, tier, interval) { @@ -279,7 +279,7 @@ class AccountApi { interval, }), }); - return await response.json(); // May throw SyntaxError + return response.json(); // May throw SyntaxError } async deleteBillingSubscription() { @@ -298,7 +298,7 @@ class AccountApi { method: "POST", headers: withBearerAuth({}, session.token()), }); - return await response.json(); // May throw SyntaxError + return response.json(); // May throw SyntaxError } async verifyPhoneNumber(phoneNumber, channel) { @@ -327,7 +327,7 @@ class AccountApi { }); } - async deletePhoneNumber(phoneNumber, code) { + async deletePhoneNumber(phoneNumber) { const url = accountPhoneUrl(config.base_url); console.log(`[AccountApi] Deleting phone number ${url}`); await fetchOrThrow(url, { @@ -369,6 +369,7 @@ class AccountApi { if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); } + return undefined; } } diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index dd3cf63d..5358cdde 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -1,7 +1,14 @@ +/* eslint-disable max-classes-per-file */ import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils"; const retryBackoffSeconds = [5, 10, 20, 30, 60, 120]; +export class ConnectionState { + static Connected = "connected"; + + static Connecting = "connecting"; +} + /** * A connection contains a single WebSocket connection for one topic. It handles its connection * status itself, including reconnect attempts and backoff. @@ -63,7 +70,7 @@ class Connection { this.ws = null; } else { const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length - 1)]; - this.retryCount++; + this.retryCount += 1; console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`); this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000); this.onStateChanged(this.subscriptionId, ConnectionState.Connecting); @@ -108,10 +115,4 @@ class Connection { } } -export class ConnectionState { - static Connected = "connected"; - - static Connecting = "connecting"; -} - export default Connection; diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js index f6316aa2..751c7bd3 100644 --- a/web/src/app/ConnectionManager.js +++ b/web/src/app/ConnectionManager.js @@ -1,6 +1,9 @@ import Connection from "./Connection"; import { hashCode } from "./utils"; +const makeConnectionId = async (subscription, user) => + user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`); + /** * The connection manager keeps track of active connections (WebSocket connections, see Connection). * @@ -69,8 +72,8 @@ class ConnectionManager { topic, user, since, - (subscriptionId, notification) => this.notificationReceived(subscriptionId, notification), - (subscriptionId, state) => this.stateChanged(subscriptionId, state) + (subId, notification) => this.notificationReceived(subId, notification), + (subId, state) => this.stateChanged(subId, state) ); this.connections.set(connectionId, connection); console.log( @@ -112,8 +115,5 @@ class ConnectionManager { } } -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; diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 2d00dea9..45792dc8 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -29,7 +29,7 @@ class Notifier { icon: logo, }); if (notification.click) { - n.onclick = (e) => openUrl(notification.click); + n.onclick = () => openUrl(notification.click); } else { n.onclick = () => onClickFallback(subscription); } @@ -87,7 +87,7 @@ class Notifier { * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification */ contextSupported() { - return location.protocol === "https:" || location.hostname.match("^127.") || location.hostname === "localhost"; + return window.location.protocol === "https:" || window.location.hostname.match("^127.") || window.location.hostname === "localhost"; } } diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index 402e36b4..b9568211 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -23,6 +23,8 @@ class Poller { const subscriptions = await subscriptionManager.all(); for (const s of subscriptions) { try { + // TODO(eslint): Switch to Promise.all + // eslint-disable-next-line no-await-in-loop await this.poll(s); } catch (e) { console.log(`[Poller] Error polling ${s.id}`, e); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index aeec3fc9..77627532 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -7,6 +7,7 @@ class SubscriptionManager { const subscriptions = await db.subscriptions.toArray(); await Promise.all( subscriptions.map(async (s) => { + // eslint-disable-next-line no-param-reassign s.new = await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(); }) ); @@ -14,7 +15,7 @@ class SubscriptionManager { } async get(subscriptionId) { - return await db.subscriptions.get(subscriptionId); + return db.subscriptions.get(subscriptionId); } async add(baseUrl, topic, internal) { @@ -40,10 +41,14 @@ class SubscriptionManager { // Add remote subscriptions const remoteIds = []; // = topicUrl(baseUrl, topic) - for (let i = 0; i < remoteSubscriptions.length; i++) { + for (let i = 0; i < remoteSubscriptions.length; i += 1) { const remote = remoteSubscriptions[i]; + // TODO(eslint): Switch to Promise.all + // eslint-disable-next-line no-await-in-loop 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; + // TODO(eslint): Switch to Promise.all + // eslint-disable-next-line no-await-in-loop await this.update(local.id, { displayName: remote.display_name, // May be undefined reservation, // May be null! @@ -53,10 +58,12 @@ class SubscriptionManager { // Remove local subscriptions that do not exist remotely const localSubscriptions = await db.subscriptions.toArray(); - for (let i = 0; i < localSubscriptions.length; i++) { + for (let i = 0; i < localSubscriptions.length; i += 1) { const local = localSubscriptions[i]; const remoteExists = remoteIds.includes(local.id); if (!local.internal && !remoteExists) { + // TODO(eslint): Switch to Promise.all + // eslint-disable-next-line no-await-in-loop await this.remove(local.id); } } @@ -101,6 +108,7 @@ class SubscriptionManager { return false; } try { + // eslint-disable-next-line no-param-reassign notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab await db.subscriptions.update(subscriptionId, { diff --git a/web/src/app/errors.js b/web/src/app/errors.js index 0d443757..28f49af1 100644 --- a/web/src/app/errors.js +++ b/web/src/app/errors.js @@ -1,37 +1,6 @@ +/* eslint-disable max-classes-per-file */ // This is a subset of, and the counterpart to errors.go -export const fetchOrThrow = async (url, options) => { - const response = await fetch(url, options); - if (response.status !== 200) { - await throwAppError(response); - } - return response; // Promise! -}; - -export const throwAppError = async (response) => { - if (response.status === 401 || response.status === 403) { - console.log(`[Error] HTTP ${response.status}`, response); - throw new UnauthorizedError(); - } - const error = await maybeToJson(response); - if (error?.code) { - console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response); - if (error.code === UserExistsError.CODE) { - throw new UserExistsError(); - } else if (error.code === TopicReservedError.CODE) { - throw new TopicReservedError(); - } else if (error.code === AccountCreateLimitReachedError.CODE) { - throw new AccountCreateLimitReachedError(); - } else if (error.code === IncorrectPasswordError.CODE) { - throw new IncorrectPasswordError(); - } else if (error?.error) { - throw new Error(`Error ${error.code}: ${error.error}`); - } - } - console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response); - throw new Error(`Unexpected response ${response.status}`); -}; - const maybeToJson = async (response) => { try { return await response.json(); @@ -77,3 +46,35 @@ export class IncorrectPasswordError extends Error { super("Password incorrect"); } } + +export const throwAppError = async (response) => { + if (response.status === 401 || response.status === 403) { + console.log(`[Error] HTTP ${response.status}`, response); + throw new UnauthorizedError(); + } + const error = await maybeToJson(response); + if (error?.code) { + console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response); + if (error.code === UserExistsError.CODE) { + throw new UserExistsError(); + } else if (error.code === TopicReservedError.CODE) { + throw new TopicReservedError(); + } else if (error.code === AccountCreateLimitReachedError.CODE) { + throw new AccountCreateLimitReachedError(); + } else if (error.code === IncorrectPasswordError.CODE) { + throw new IncorrectPasswordError(); + } else if (error?.error) { + throw new Error(`Error ${error.code}: ${error.error}`); + } + } + console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response); + throw new Error(`Unexpected response ${response.status}`); +}; + +export const fetchOrThrow = async (url, options) => { + const response = await fetch(url, options); + if (response.status !== 200) { + await throwAppError(response); + } + return response; // Promise! +}; diff --git a/web/src/app/utils.js b/web/src/app/utils.js index e8c98ec7..0af10330 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -9,6 +9,10 @@ import pop from "../sounds/pop.mp3"; import popSwoosh from "../sounds/pop-swoosh.mp3"; import config from "./config"; +export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; +export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); +export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; +export const expandSecureUrl = (url) => `https://${url}`; export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`; export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://"); @@ -28,13 +32,11 @@ export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`; -export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; -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) => url.match(/^https?:\/\/.+/); +export const disallowedTopic = (topic) => config.disallowed_topics.includes(topic); + export const validTopic = (topic) => { if (disallowedTopic(topic)) { return false; @@ -42,8 +44,6 @@ export const validTopic = (topic) => { return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app! }; -export const disallowedTopic = (topic) => config.disallowed_topics.includes(topic); - export const topicDisplayName = (subscription) => { if (subscription.displayName) { return subscription.displayName; @@ -67,13 +67,6 @@ const toEmojis = (tags) => { return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]); }; -export const formatTitleWithDefault = (m, fallback) => { - if (m.title) { - return formatTitle(m); - } - return fallback; -}; - export const formatTitle = (m) => { const emojiList = toEmojis(m.tags); if (emojiList.length > 0) { @@ -82,6 +75,13 @@ export const formatTitle = (m) => { return m.title; }; +export const formatTitleWithDefault = (m, fallback) => { + if (m.title) { + return formatTitle(m); + } + return fallback; +}; + export const formatMessage = (m) => { if (m.title) { return m.message; @@ -98,6 +98,25 @@ export const unmatchedTags = (tags) => { return tags.filter((tag) => !(tag in emojis)); }; +export const encodeBase64 = (s) => Base64.encode(s); + +export const encodeBase64Url = (s) => Base64.encodeURI(s); + +export const bearerAuth = (token) => `Bearer ${token}`; + +export const basicAuth = (username, password) => `Basic ${encodeBase64(`${username}:${password}`)}`; + +export const withBearerAuth = (headers, token) => ({ ...headers, Authorization: bearerAuth(token) }); + +export const maybeWithBearerAuth = (headers, token) => { + if (token) { + return withBearerAuth(headers, token); + } + return headers; +}; + +export const withBasicAuth = (headers, username, password) => ({ ...headers, Authorization: basicAuth(username, password) }); + export const maybeWithAuth = (headers, user) => { if (user && user.password) { return withBasicAuth(headers, user.username, user.password); @@ -108,31 +127,6 @@ export const maybeWithAuth = (headers, user) => { return headers; }; -export const maybeWithBearerAuth = (headers, token) => { - if (token) { - return withBearerAuth(headers, token); - } - return headers; -}; - -export const withBasicAuth = (headers, username, password) => { - headers.Authorization = basicAuth(username, password); - return headers; -}; - -export const basicAuth = (username, password) => `Basic ${encodeBase64(`${username}:${password}`)}`; - -export const withBearerAuth = (headers, token) => { - headers.Authorization = bearerAuth(token); - return headers; -}; - -export const bearerAuth = (token) => `Bearer ${token}`; - -export const encodeBase64 = (s) => Base64.encode(s); - -export const encodeBase64Url = (s) => Base64.encodeURI(s); - export const maybeAppendActionErrors = (message, notification) => { const actionErrors = (notification.actions ?? []) .map((action) => action.error) @@ -147,10 +141,12 @@ export const maybeAppendActionErrors = (message, notification) => { export const shuffle = (arr) => { let j; let x; - for (let index = arr.length - 1; index > 0; index--) { + for (let index = arr.length - 1; index > 0; index -= 1) { j = Math.floor(Math.random() * (index + 1)); x = arr[index]; + // eslint-disable-next-line no-param-reassign arr[index] = arr[j]; + // eslint-disable-next-line no-param-reassign arr[j] = x; } return arr; @@ -165,9 +161,11 @@ export const splitNoEmpty = (s, delimiter) => /** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */ export const hashCode = async (s) => { let hash = 0; - for (let i = 0; i < s.length; i++) { + for (let i = 0; i < s.length; i += 1) { const char = s.charCodeAt(i); + // eslint-disable-next-line no-bitwise hash = (hash << 5) - hash + char; + // eslint-disable-next-line no-bitwise hash &= hash; // Convert to 32bit integer } return hash; @@ -248,6 +246,7 @@ export const playSound = async (id) => { }; // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch +// eslint-disable-next-line func-style export async function* fetchLinesIterator(fileURL, headers) { const utf8Decoder = new TextDecoder("utf-8"); const response = await fetch(fileURL, { @@ -267,9 +266,12 @@ export async function* fetchLinesIterator(fileURL, headers) { break; } const remainder = chunk.substr(startIndex); + // eslint-disable-next-line no-await-in-loop ({ value: chunk, done: readerDone } = await reader.read()); chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : ""); - startIndex = re.lastIndex = 0; + startIndex = 0; + re.lastIndex = 0; + // eslint-disable-next-line no-continue continue; } yield chunk.substring(startIndex, result.index); @@ -283,7 +285,8 @@ export async function* fetchLinesIterator(fileURL, headers) { export const randomAlphanumericString = (len) => { const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let id = ""; - for (let i = 0; i < len; i++) { + for (let i = 0; i < len; i += 1) { + // eslint-disable-next-line no-bitwise id += alphabet[(Math.random() * alphabet.length) | 0]; } return id; diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index d6f74843..535ccbc7 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -439,23 +439,6 @@ const AddPhoneNumberDialog = (props) => { const [verificationCodeSent, setVerificationCodeSent] = useState(false); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const handleDialogSubmit = async () => { - if (!verificationCodeSent) { - await verifyPhone(); - } else { - await checkVerifyPhone(); - } - }; - - const handleCancel = () => { - if (verificationCodeSent) { - setVerificationCodeSent(false); - setCode(""); - } else { - props.onClose(); - } - }; - const verifyPhone = async () => { try { setSending(true); @@ -490,6 +473,23 @@ const AddPhoneNumberDialog = (props) => { } }; + const handleDialogSubmit = async () => { + if (!verificationCodeSent) { + await verifyPhone(); + } else { + await checkVerifyPhone(); + } + }; + + const handleCancel = () => { + if (verificationCodeSent) { + setVerificationCodeSent(false); + setCode(""); + } else { + props.onClose(); + } + }; + return ( {t("account_basics_phone_numbers_dialog_title")} @@ -771,10 +771,6 @@ const Tokens = () => { setDialogOpen(false); }; - const handleDialogSubmit = async (user) => { - setDialogOpen(false); - // - }; return ( @@ -998,7 +994,6 @@ const TokenDialog = (props) => { const TokenDeleteDialog = (props) => { const { t } = useTranslation(); - const [error, setError] = useState(""); const handleSubmit = async () => { try { @@ -1008,8 +1003,6 @@ const TokenDeleteDialog = (props) => { console.log(`[Account] Error deleting token`, e); if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); - } else { - setError(e.message); } } }; diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 661f6eb7..607d125b 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { createContext, Suspense, useContext, useEffect, useState } from "react"; +import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react"; import Box from "@mui/material/Box"; import { ThemeProvider } from "@mui/material/styles"; import CssBaseline from "@mui/material/CssBaseline"; @@ -30,11 +30,14 @@ export const AccountContext = createContext(null); const App = () => { const [account, setAccount] = useState(null); + + const contextValue = useMemo(() => ({ account, setAccount }), [account, setAccount]); + return ( }> - + @@ -56,6 +59,10 @@ const App = () => { ); }; +const updateTitle = (newNotificationsCount) => { + document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy"; +}; + const Layout = () => { const params = useParams(); const { account, setAccount } = useContext(AccountContext); @@ -115,7 +122,7 @@ const Main = (props) => ( 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]), + backgroundColor: ({ palette }) => (palette.mode === "light" ? palette.grey[100] : palette.grey[900]), }} > {props.children} @@ -127,15 +134,11 @@ const Loader = () => ( open sx={{ zIndex: 100000, - backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]), + backgroundColor: ({ palette }) => (palette.mode === "light" ? palette.grey[100] : palette.grey[900]), }} > ); -const updateTitle = (newNotificationsCount) => { - document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy"; -}; - export default App; diff --git a/web/src/components/EmojiPicker.jsx b/web/src/components/EmojiPicker.jsx index 6aa8e3c5..da8d4369 100644 --- a/web/src/components/EmojiPicker.jsx +++ b/web/src/components/EmojiPicker.jsx @@ -79,8 +79,6 @@ const EmojiPicker = (props) => { inputProps={{ role: "searchbox", "aria-label": t("emoji_picker_search_placeholder"), - }} - InputProps={{ endAdornment: ( @@ -132,6 +130,18 @@ const Category = (props) => { ); }; +const emojiMatches = (emoji, words) => { + if (words.length === 0) { + return true; + } + for (const word of words) { + if (emoji.searchBase.indexOf(word) === -1) { + return false; + } + } + return true; +}; + const Emoji = (props) => { const { emoji } = props; const matches = emojiMatches(emoji, props.search); @@ -158,16 +168,4 @@ const EmojiDiv = styled("div")({ }, }); -const emojiMatches = (emoji, words) => { - if (words.length === 0) { - return true; - } - for (const word of words) { - if (emoji.searchBase.indexOf(word) === -1) { - return false; - } - } - return true; -}; - export default EmojiPicker; diff --git a/web/src/components/ErrorBoundary.jsx b/web/src/components/ErrorBoundary.jsx index a8e67626..ef5d9184 100644 --- a/web/src/components/ErrorBoundary.jsx +++ b/web/src/components/ErrorBoundary.jsx @@ -69,16 +69,6 @@ class ErrorBoundaryImpl extends React.Component { navigator.clipboard.writeText(stack); } - render() { - if (this.state.error) { - if (this.state.unsupportedIndexedDB) { - return this.renderUnsupportedIndexedDB(); - } - return this.renderError(); - } - return this.props.children; - } - renderUnsupportedIndexedDB() { const { t } = this.props; return ( @@ -130,6 +120,16 @@ class ErrorBoundaryImpl extends React.Component { ); } + + render() { + if (this.state.error) { + if (this.state.unsupportedIndexedDB) { + return this.renderUnsupportedIndexedDB(); + } + return this.renderError(); + } + return this.props.children; + } } const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx index 81353627..6047c1c4 100644 --- a/web/src/components/Navigation.jsx +++ b/web/src/components/Navigation.jsx @@ -85,6 +85,10 @@ const NavList = (props) => { setSubscribeDialogKey((prev) => prev + 1); }; + const handleRequestNotificationPermission = () => { + notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted)); + }; + const handleSubscribeSubmit = (subscription) => { console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); handleSubscribeReset(); @@ -92,10 +96,6 @@ const NavList = (props) => { handleRequestNotificationPermission(); }; - const handleRequestNotificationPermission = () => { - notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted)); - }; - const handleAccountClick = () => { accountApi.sync(); // Dangle! navigate(routes.account); diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 5b611fb9..fd246c48 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -34,6 +34,13 @@ import logoOutline from "../img/ntfy-outline.svg"; import AttachmentIcon from "./AttachmentIcon"; import { useAutoSubscribe } from "./hooks"; +const priorityFiles = { + 1: priority1, + 2: priority2, + 4: priority4, + 5: priority5, +}; + export const AllSubscriptions = () => { const { subscriptions } = useOutletContext(); if (!subscriptions) { @@ -131,6 +138,25 @@ const NotificationList = (props) => { ); }; +/** + * Replace links with components; this is a combination of the genius function + * in [1] and the regex in [2]. + * + * [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760 + * [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9 + */ +const autolink = (s) => { + const parts = s.split(/(\bhttps?:\/\/[-A-Z0-9+\u0026\u2019@#/%?=()~_|!:,.;]*[-A-Z0-9+\u0026@#/%=~()_|]\b)/gi); + for (let i = 1; i < parts.length; i += 2) { + parts[i] = ( + + {shortUrl(parts[i])} + + ); + } + return <>{parts}; +}; + const NotificationItem = (props) => { const { t } = useTranslation(); const { notification } = props; @@ -248,32 +274,6 @@ const NotificationItem = (props) => { ); }; -/** - * Replace links with components; this is a combination of the genius function - * in [1] and the regex in [2]. - * - * [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760 - * [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9 - */ -const autolink = (s) => { - const parts = s.split(/(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi); - for (let i = 1; i < parts.length; i += 2) { - parts[i] = ( - - {shortUrl(parts[i])} - - ); - } - return <>{parts}; -}; - -const priorityFiles = { - 1: priority1, - 2: priority2, - 4: priority4, - 5: priority5, -}; - const Attachment = (props) => { const { t } = useTranslation(); const { attachment } = props; @@ -414,6 +414,52 @@ const UserActions = (props) => ( ); +const ACTION_PROGRESS_ONGOING = 1; +const ACTION_PROGRESS_SUCCESS = 2; +const ACTION_PROGRESS_FAILED = 3; + +const ACTION_LABEL_SUFFIX = { + [ACTION_PROGRESS_ONGOING]: " …", + [ACTION_PROGRESS_SUCCESS]: " ✔", + [ACTION_PROGRESS_FAILED]: " ❌", +}; + +const updateActionStatus = (notification, action, progress, error) => { + // TODO(eslint): Fix by spreading? Does the code depend on the change, though? + // eslint-disable-next-line no-param-reassign + notification.actions = notification.actions.map((a) => { + if (a.id !== action.id) { + return a; + } + return { ...a, progress, error }; + }); + subscriptionManager.updateNotification(notification); +}; + +const performHttpAction = async (notification, action) => { + console.log(`[Notifications] Performing HTTP user action`, action); + try { + updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null); + const response = await fetch(action.url, { + method: action.method ?? "POST", + headers: action.headers ?? {}, + // This must not null-coalesce to a non nullish value. Otherwise, the fetch API + // will reject it for "having a body" + body: action.body, + }); + console.log(`[Notifications] HTTP user action response`, response); + const success = response.status >= 200 && response.status <= 299; + if (success) { + updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null); + } else { + updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`); + } + } catch (e) { + console.log(`[Notifications] HTTP action failed`, e); + updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`); + } +}; + const UserAction = (props) => { const { t } = useTranslation(); const { notification } = props; @@ -468,53 +514,9 @@ const UserAction = (props) => { return null; // Others }; -const performHttpAction = async (notification, action) => { - console.log(`[Notifications] Performing HTTP user action`, action); - try { - updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null); - const response = await fetch(action.url, { - method: action.method ?? "POST", - headers: action.headers ?? {}, - // This must not null-coalesce to a non nullish value. Otherwise, the fetch API - // will reject it for "having a body" - body: action.body, - }); - console.log(`[Notifications] HTTP user action response`, response); - const success = response.status >= 200 && response.status <= 299; - if (success) { - updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null); - } else { - updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`); - } - } catch (e) { - console.log(`[Notifications] HTTP action failed`, e); - updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`); - } -}; - -const updateActionStatus = (notification, action, progress, error) => { - notification.actions = notification.actions.map((a) => { - if (a.id !== action.id) { - return a; - } - return { ...a, progress, error }; - }); - subscriptionManager.updateNotification(notification); -}; - -const ACTION_PROGRESS_ONGOING = 1; -const ACTION_PROGRESS_SUCCESS = 2; -const ACTION_PROGRESS_FAILED = 3; - -const ACTION_LABEL_SUFFIX = { - [ACTION_PROGRESS_ONGOING]: " …", - [ACTION_PROGRESS_SUCCESS]: " ✔", - [ACTION_PROGRESS_FAILED]: " ❌", -}; - const NoNotifications = (props) => { const { t } = useTranslation(); - const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic); + const topicShortUrlResolved = topicShortUrl(props.subscription.baseUrl, props.subscription.topic); return ( @@ -525,7 +527,10 @@ const NoNotifications = (props) => { {t("notifications_none_for_topic_description")} {t("notifications_example")}:
- $ curl -d "Hi" {shortUrl} + + {'$ curl -d "Hi" '} + {topicShortUrlResolved} +
@@ -537,7 +542,7 @@ const NoNotifications = (props) => { const NoNotificationsWithoutSubscription = (props) => { const { t } = useTranslation(); const subscription = props.subscriptions[0]; - const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); + const topicShortUrlResolved = topicShortUrl(subscription.baseUrl, subscription.topic); return ( @@ -548,7 +553,10 @@ const NoNotificationsWithoutSubscription = (props) => { {t("notifications_none_for_any_description")} {t("notifications_example")}:
- $ curl -d "Hi" {shortUrl} + + {'$ curl -d "Hi" '} + {topicShortUrlResolved} +
diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index a2ff1dc4..cdd8ed55 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -47,9 +47,22 @@ 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 maybeUpdateAccountSettings = async (payload) => { + if (!session.exists()) { + return; + } + try { + await accountApi.updateSettings(payload); + } catch (e) { + console.log(`[Preferences] Error updating account settings`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } + } +}; + const Preferences = () => ( @@ -181,10 +194,12 @@ const DeleteAfter = () => { }, }); }; + if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0" return null; // While loading } + const description = (() => { switch (deleteAfter) { case 0: @@ -197,8 +212,11 @@ const DeleteAfter = () => { return t("prefs_notifications_delete_after_one_week_description"); case 2592000: return t("prefs_notifications_delete_after_one_month_description"); + default: + return ""; } })(); + return ( @@ -674,18 +692,4 @@ const ReservationsTable = (props) => { ); }; -const maybeUpdateAccountSettings = async (payload) => { - if (!session.exists()) { - return; - } - try { - await accountApi.updateSettings(payload); - } catch (e) { - console.log(`[Preferences] Error updating account settings`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } - } -}; - export default Preferences; diff --git a/web/src/components/PublishDialog.jsx b/web/src/components/PublishDialog.jsx index 53326fbf..6a89aef0 100644 --- a/web/src/components/PublishDialog.jsx +++ b/web/src/components/PublishDialog.jsx @@ -171,34 +171,33 @@ const PublishDialog = (props) => { const checkAttachmentLimits = async (file) => { try { - const account = await accountApi.get(); - const fileSizeLimit = account.limits.attachment_file_size ?? 0; - const remainingBytes = account.stats.attachment_total_size_remaining; + const apiAccount = await accountApi.get(); + const fileSizeLimit = apiAccount.limits.attachment_file_size ?? 0; + const remainingBytes = apiAccount.stats.attachment_total_size_remaining; const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit; const quotaReached = remainingBytes > 0 && file.size > remainingBytes; if (fileSizeLimitReached && quotaReached) { - return setAttachFileError( + setAttachFileError( t("publish_dialog_attachment_limits_file_and_quota_reached", { fileSizeLimit: formatBytes(fileSizeLimit), remainingBytes: formatBytes(remainingBytes), }) ); - } - if (fileSizeLimitReached) { - return setAttachFileError( + } else if (fileSizeLimitReached) { + setAttachFileError( t("publish_dialog_attachment_limits_file_reached", { fileSizeLimit: formatBytes(fileSizeLimit), }) ); - } - if (quotaReached) { - return setAttachFileError( + } else if (quotaReached) { + setAttachFileError( t("publish_dialog_attachment_limits_quota_reached", { remainingBytes: formatBytes(remainingBytes), }) ); + } else { + setAttachFileError(""); } - setAttachFileError(""); } catch (e) { console.log(`[PublishDialog] Retrieving attachment limits failed`, e); if (e instanceof UnauthorizedError) { @@ -213,6 +212,13 @@ const PublishDialog = (props) => { attachFileInput.current.click(); }; + const updateAttachFile = async (file) => { + setAttachFile(file); + setFilename(file.name); + props.onResetOpenMode(); + await checkAttachmentLimits(file); + }; + const handleAttachFileChanged = async (ev) => { await updateAttachFile(ev.target.files[0]); }; @@ -223,13 +229,6 @@ const PublishDialog = (props) => { await updateAttachFile(ev.dataTransfer.files[0]); }; - const updateAttachFile = async (file) => { - setAttachFile(file); - setFilename(file.name); - props.onResetOpenMode(); - await checkAttachmentLimits(file); - }; - const handleAttachFileDragLeave = () => { setDropZone(false); if (props.openMode === PublishDialog.OPEN_MODE_DRAG) { @@ -242,7 +241,7 @@ const PublishDialog = (props) => { }; const handleEmojiPick = (emoji) => { - setTags((tags) => (tags.trim() ? `${tags.trim()}, ${emoji}` : emoji)); + setTags((prevTags) => (prevTags.trim() ? `${prevTags.trim()}, ${emoji}` : emoji)); }; const handleEmojiClose = () => { @@ -374,23 +373,23 @@ const PublishDialog = (props) => { "aria-label": t("publish_dialog_priority_label"), }} > - {[5, 4, 3, 2, 1].map((priority) => ( + {[5, 4, 3, 2, 1].map((priorityMenuItem) => (
{t("notifications_priority_x", -
{priorities[priority].label}
+
{priorities[priorityMenuItem].label}
))} @@ -469,6 +468,8 @@ const PublishDialog = (props) => { }} > {account?.phone_numbers?.map((phoneNumber, i) => ( + // TODO(eslint): Possibly just use the phone number as a key? + // eslint-disable-next-line react/no-array-index-key {t("publish_dialog_call_item", { number: phoneNumber })} @@ -716,7 +717,7 @@ const Row = (props) => ( ); const ClosableRow = (props) => { - const closable = props.hasOwnProperty("closable") ? props.closable : true; + const closable = props.closable !== undefined ? props.closable : true; return ( {props.children} @@ -823,10 +824,7 @@ const ExpandingTextField = (props) => { variant="standard" sx={{ width: `${textWidth}px`, borderBottom: "none" }} InputProps={{ - style: { fontSize: theme.typography[props.variant].fontSize }, - }} - inputProps={{ - style: { paddingBottom: 0, paddingTop: 0 }, + style: { fontSize: theme.typography[props.variant].fontSize, paddingBottom: 0, paddingTop: 0 }, "aria-label": props.placeholder, }} disabled={props.disabled} @@ -840,6 +838,7 @@ const DropArea = (props) => { // This is where we could disallow certain files to be dragged in. // For now we allow all files. + // eslint-disable-next-line no-param-reassign ev.dataTransfer.dropEffect = "copy"; ev.preventDefault(); }; diff --git a/web/src/components/SubscribeDialog.jsx b/web/src/components/SubscribeDialog.jsx index df270c47..aae48e58 100644 --- a/web/src/components/SubscribeDialog.jsx +++ b/web/src/components/SubscribeDialog.jsx @@ -25,6 +25,21 @@ import { ReserveLimitChip } from "./SubscriptionPopup"; const publicBaseUrl = "https://ntfy.sh"; +export const subscribeTopic = async (baseUrl, topic) => { + const subscription = await subscriptionManager.add(baseUrl, topic); + if (session.exists()) { + try { + await accountApi.addSubscription(baseUrl, topic); + } catch (e) { + console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } + } + } + return subscription; +}; + const SubscribeDialog = (props) => { const [baseUrl, setBaseUrl] = useState(""); const [topic, setTopic] = useState(""); @@ -296,19 +311,4 @@ const LoginPage = (props) => { ); }; -export const subscribeTopic = async (baseUrl, topic) => { - const subscription = await subscriptionManager.add(baseUrl, topic); - if (session.exists()) { - try { - await accountApi.addSubscription(baseUrl, topic); - } catch (e) { - console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } - } - } - return subscription; -}; - export default SubscribeDialog; diff --git a/web/src/components/SubscriptionPopup.jsx b/web/src/components/SubscriptionPopup.jsx index 52ca3968..7e747e41 100644 --- a/web/src/components/SubscriptionPopup.jsx +++ b/web/src/components/SubscriptionPopup.jsx @@ -241,8 +241,6 @@ const DisplayNameDialog = (props) => { inputProps={{ maxLength: 64, "aria-label": t("display_name_dialog_placeholder"), - }} - InputProps={{ endAdornment: ( setDisplayName("")} edge="end"> @@ -292,20 +290,17 @@ const LimitReachedChip = () => { ); }; -export const ProChip = () => { - const { t } = useTranslation(); - return ( - - ); -}; +export const ProChip = () => ( + +); diff --git a/web/src/components/UpgradeDialog.jsx b/web/src/components/UpgradeDialog.jsx index 41398da4..c3def5ba 100644 --- a/web/src/components/UpgradeDialog.jsx +++ b/web/src/components/UpgradeDialog.jsx @@ -24,6 +24,33 @@ import session from "../app/Session"; import accountApi, { SubscriptionInterval } from "../app/AccountApi"; import theme from "./theme"; +const Feature = (props) => {props.children}; + +const NoFeature = (props) => {props.children}; + +const FeatureItem = (props) => ( + + + {props.feature && } + {!props.feature && } + + {props.children}
} /> + +); + +const Action = { + REDIRECT_SIGNUP: 1, + CREATE_SUBSCRIPTION: 2, + UPDATE_SUBSCRIPTION: 3, + CANCEL_SUBSCRIPTION: 4, +}; + +const Banner = { + CANCEL_WARNING: 1, + PRORATION_INFO: 2, + RESERVATIONS_WARNING: 3, +}; + const UpgradeDialog = (props) => { const { t } = useTranslation(); const { account } = useContext(AccountContext); // May be undefined! @@ -120,12 +147,12 @@ const UpgradeDialog = (props) => { discount = Math.round(((newTier.prices.month * 12) / newTier.prices.year - 1) * 100); } else { let n = 0; - for (const t of tiers) { - if (t.prices) { - const tierDiscount = Math.round(((t.prices.month * 12) / t.prices.year - 1) * 100); + for (const tier of tiers) { + if (tier.prices) { + const tierDiscount = Math.round(((tier.prices.month * 12) / tier.prices.year - 1) * 100); if (tierDiscount > discount) { discount = tierDiscount; - n++; + n += 1; } } } @@ -210,7 +237,7 @@ const UpgradeDialog = (props) => { , }} @@ -396,31 +423,4 @@ const TierCard = (props) => { ); }; -const Feature = (props) => {props.children}; - -const NoFeature = (props) => {props.children}; - -const FeatureItem = (props) => ( - - - {props.feature && } - {!props.feature && } - - {props.children}
} /> - -); - -const Action = { - REDIRECT_SIGNUP: 1, - CREATE_SUBSCRIPTION: 2, - UPDATE_SUBSCRIPTION: 3, - CANCEL_SUBSCRIPTION: 4, -}; - -const Banner = { - CANCEL_WARNING: 1, - PRORATION_INFO: 2, - RESERVATIONS_WARNING: 3, -}; - export default UpgradeDialog; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 860e9f94..b9c5536d 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -22,15 +22,6 @@ export const useConnectionListeners = (account, subscriptions, users) => { // Register listeners for incoming messages, and connection state changes useEffect( () => { - const handleMessage = async (subscriptionId, message) => { - const subscription = await subscriptionManager.get(subscriptionId); - if (subscription.internal) { - await handleInternalMessage(message); - } else { - await handleNotification(subscriptionId, message); - } - }; - const handleInternalMessage = async (message) => { console.log(`[ConnectionListener] Received message on sync topic`, message.message); try { @@ -53,8 +44,19 @@ export const useConnectionListeners = (account, subscriptions, users) => { await notifier.notify(subscriptionId, notification, defaultClickAction); } }; + + const handleMessage = async (subscriptionId, message) => { + const subscription = await subscriptionManager.get(subscriptionId); + if (subscription.internal) { + await handleInternalMessage(message); + } else { + await handleNotification(subscriptionId, message); + } + }; + connectionManager.registerStateListener(subscriptionManager.updateState); connectionManager.registerMessageListener(handleMessage); + return () => { connectionManager.resetStateListener(); connectionManager.resetMessageListener();