Run prettier

This commit is contained in:
nimbleghost 2023-05-23 21:13:01 +02:00
parent 206ea312bf
commit 6f6a2d1f69
49 changed files with 22902 additions and 6633 deletions

View file

@ -15,5 +15,15 @@ var config = {
enable_emails: true, enable_emails: true,
enable_calls: true, enable_calls: true,
billing_contact: "", billing_contact: "",
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"] disallowed_topics: [
"docs",
"static",
"file",
"app",
"account",
"settings",
"signup",
"login",
"v1",
],
}; };

View file

@ -1,28 +1,38 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<title>ntfy web</title> <title>ntfy web</title>
<!-- Mobile view --> <!-- Mobile view -->
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"> <meta
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> name="viewport"
<meta name="HandheldFriendly" content="true"> content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"
/>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="HandheldFriendly" content="true" />
<!-- Mobile browsers, background color --> <!-- Mobile browsers, background color -->
<meta name="theme-color" content="#317f6f"> <meta name="theme-color" content="#317f6f" />
<meta name="msapplication-navbutton-color" content="#317f6f"> <meta name="msapplication-navbutton-color" content="#317f6f" />
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f"> <meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
<!-- Favicon, see favicon.io --> <!-- Favicon, see favicon.io -->
<link rel="icon" type="image/png" href="%PUBLIC_URL%/static/images/favicon.ico"> <link
rel="icon"
type="image/png"
href="%PUBLIC_URL%/static/images/favicon.ico"
/>
<!-- Previews in Google, Slack, WhatsApp, etc. --> <!-- Previews in Google, Slack, WhatsApp, etc. -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" /> <meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="ntfy web" /> <meta property="og:site_name" content="ntfy web" />
<meta property="og:title" content="ntfy web" /> <meta property="og:title" content="ntfy web" />
<meta property="og:description" content="ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." /> <meta
property="og:description"
content="ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy."
/>
<meta property="og:image" content="%PUBLIC_URL%/static/images/ntfy.png" /> <meta property="og:image" content="%PUBLIC_URL%/static/images/ntfy.png" />
<meta property="og:url" content="https://ntfy.sh" /> <meta property="og:url" content="https://ntfy.sh" />
@ -30,15 +40,25 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<!-- Style overrides & fonts --> <!-- Style overrides & fonts -->
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/app.css" type="text/css"> <link
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css"> rel="stylesheet"
</head> href="%PUBLIC_URL%/static/css/app.css"
<body> type="text/css"
/>
<link
rel="stylesheet"
href="%PUBLIC_URL%/static/css/fonts.css"
type="text/css"
/>
</head>
<body>
<noscript> <noscript>
ntfy web requires JavaScript, but you can also use the <a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a> ntfy web requires JavaScript, but you can also use the
or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to subscribe. <a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a> or
<a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to
subscribe.
</noscript> </noscript>
<div id="root"></div> <div id="root"></div>
<script src="%PUBLIC_URL%/config.js"></script> <script src="%PUBLIC_URL%/config.js"></script>
</body> </body>
</html> </html>

View file

@ -1,6 +1,7 @@
/* web app styling overrides */ /* web app styling overrides */
a, a:visited { a,
a:visited {
color: #338574; color: #338574;
} }

View file

@ -2,36 +2,32 @@
/* roboto-300 - latin */ /* roboto-300 - latin */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
src: local(''), src: local(""), url("../fonts/roboto-v29-latin-300.woff2") format("woff2");
url('../fonts/roboto-v29-latin-300.woff2') format('woff2');
} }
/* roboto-regular - latin */ /* roboto-regular - latin */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: local(''), src: local(""), url("../fonts/roboto-v29-latin-regular.woff2") format("woff2");
url('../fonts/roboto-v29-latin-regular.woff2') format('woff2');
} }
/* roboto-500 - latin */ /* roboto-500 - latin */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
src: local(''), src: local(""), url("../fonts/roboto-v29-latin-500.woff2") format("woff2");
url('../fonts/roboto-v29-latin-500.woff2') format('woff2');
} }
/* roboto-700 - latin */ /* roboto-700 - latin */
@font-face { @font-face {
font-family: 'Roboto'; font-family: "Roboto";
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
src: local(''), src: local(""), url("../fonts/roboto-v29-latin-700.woff2") format("woff2");
url('../fonts/roboto-v29-latin-700.woff2') format('woff2');
} }

View file

@ -13,14 +13,14 @@ import {
maybeWithBearerAuth, maybeWithBearerAuth,
tiersUrl, tiersUrl,
withBasicAuth, withBasicAuth,
withBearerAuth withBearerAuth,
} from "./utils"; } from "./utils";
import session from "./Session"; import session from "./Session";
import subscriptionManager from "./SubscriptionManager"; import subscriptionManager from "./SubscriptionManager";
import i18n from "i18next"; import i18n from "i18next";
import prefs from "./Prefs"; import prefs from "./Prefs";
import routes from "../components/routes"; import routes from "../components/routes";
import {fetchOrThrow, UnauthorizedError} from "./errors"; import { fetchOrThrow, UnauthorizedError } from "./errors";
const delayMillis = 45000; // 45 seconds const delayMillis = 45000; // 45 seconds
const intervalMillis = 900000; // 15 minutes const intervalMillis = 900000; // 15 minutes
@ -45,7 +45,7 @@ class AccountApi {
console.log(`[AccountApi] Checking auth for ${url}`); console.log(`[AccountApi] Checking auth for ${url}`);
const response = await fetchOrThrow(url, { const response = await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBasicAuth({}, user.username, user.password) headers: withBasicAuth({}, user.username, user.password),
}); });
const json = await response.json(); // May throw SyntaxError const json = await response.json(); // May throw SyntaxError
if (!json.token) { if (!json.token) {
@ -56,10 +56,12 @@ class AccountApi {
async logout() { async logout() {
const url = accountTokenUrl(config.base_url); const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`); console.log(
`[AccountApi] Logging out from ${url} using token ${session.token()}`
);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "DELETE", method: "DELETE",
headers: withBearerAuth({}, session.token()) headers: withBearerAuth({}, session.token()),
}); });
} }
@ -67,12 +69,12 @@ class AccountApi {
const url = accountUrl(config.base_url); const url = accountUrl(config.base_url);
const body = JSON.stringify({ const body = JSON.stringify({
username: username, username: username,
password: password password: password,
}); });
console.log(`[AccountApi] Creating user account ${url}`); console.log(`[AccountApi] Creating user account ${url}`);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "POST", method: "POST",
body: body body: body,
}); });
} }
@ -80,7 +82,7 @@ class AccountApi {
const url = accountUrl(config.base_url); const url = accountUrl(config.base_url);
console.log(`[AccountApi] Fetching user account ${url}`); console.log(`[AccountApi] Fetching user account ${url}`);
const response = await fetchOrThrow(url, { const response = await fetchOrThrow(url, {
headers: maybeWithBearerAuth({}, session.token()) // GET /v1/account endpoint can be called by anonymous headers: maybeWithBearerAuth({}, session.token()), // GET /v1/account endpoint can be called by anonymous
}); });
const account = await response.json(); // May throw SyntaxError const account = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Account`, account); console.log(`[AccountApi] Account`, account);
@ -97,8 +99,8 @@ class AccountApi {
method: "DELETE", method: "DELETE",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
password: password password: password,
}) }),
}); });
} }
@ -110,8 +112,8 @@ class AccountApi {
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
password: currentPassword, password: currentPassword,
new_password: newPassword new_password: newPassword,
}) }),
}); });
} }
@ -119,13 +121,13 @@ class AccountApi {
const url = accountTokenUrl(config.base_url); const url = accountTokenUrl(config.base_url);
const body = { const body = {
label: label, label: label,
expires: (expires > 0) ? Math.floor(Date.now() / 1000) + expires : 0 expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0,
}; };
console.log(`[AccountApi] Creating user access token ${url}`); console.log(`[AccountApi] Creating user access token ${url}`);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify(body) body: JSON.stringify(body),
}); });
} }
@ -133,7 +135,7 @@ class AccountApi {
const url = accountTokenUrl(config.base_url); const url = accountTokenUrl(config.base_url);
const body = { const body = {
token: token, token: token,
label: label label: label,
}; };
if (expires > 0) { if (expires > 0) {
body.expires = Math.floor(Date.now() / 1000) + expires; body.expires = Math.floor(Date.now() / 1000) + expires;
@ -142,7 +144,7 @@ class AccountApi {
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "PATCH", method: "PATCH",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify(body) body: JSON.stringify(body),
}); });
} }
@ -151,7 +153,7 @@ class AccountApi {
console.log(`[AccountApi] Extending user access token ${url}`); console.log(`[AccountApi] Extending user access token ${url}`);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "PATCH", method: "PATCH",
headers: withBearerAuth({}, session.token()) headers: withBearerAuth({}, session.token()),
}); });
} }
@ -160,7 +162,7 @@ class AccountApi {
console.log(`[AccountApi] Deleting user access token ${url}`); console.log(`[AccountApi] Deleting user access token ${url}`);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "DELETE", method: "DELETE",
headers: withBearerAuth({"X-Token": token}, session.token()) headers: withBearerAuth({ "X-Token": token }, session.token()),
}); });
} }
@ -171,7 +173,7 @@ class AccountApi {
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "PATCH", method: "PATCH",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: body body: body,
}); });
} }
@ -179,13 +181,13 @@ class AccountApi {
const url = accountSubscriptionUrl(config.base_url); const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify({ const body = JSON.stringify({
base_url: baseUrl, base_url: baseUrl,
topic: topic topic: topic,
}); });
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, { const response = await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: body body: body,
}); });
const subscription = await response.json(); // May throw SyntaxError const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription); console.log(`[AccountApi] Subscription`, subscription);
@ -197,13 +199,13 @@ class AccountApi {
const body = JSON.stringify({ const body = JSON.stringify({
base_url: baseUrl, base_url: baseUrl,
topic: topic, topic: topic,
...payload ...payload,
}); });
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, { const response = await fetchOrThrow(url, {
method: "PATCH", method: "PATCH",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: body body: body,
}); });
const subscription = await response.json(); // May throw SyntaxError const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription); console.log(`[AccountApi] Subscription`, subscription);
@ -216,7 +218,7 @@ class AccountApi {
const headers = { const headers = {
"X-BaseURL": baseUrl, "X-BaseURL": baseUrl,
"X-Topic": topic, "X-Topic": topic,
} };
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "DELETE", method: "DELETE",
headers: withBearerAuth(headers, session.token()), headers: withBearerAuth(headers, session.token()),
@ -225,14 +227,16 @@ class AccountApi {
async upsertReservation(topic, everyone) { async upsertReservation(topic, everyone) {
const url = accountReservationUrl(config.base_url); const url = accountReservationUrl(config.base_url);
console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`); console.log(
`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`
);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
topic: topic, topic: topic,
everyone: everyone everyone: everyone,
}) }),
}); });
} }
@ -240,11 +244,11 @@ class AccountApi {
const url = accountReservationSingleUrl(config.base_url, topic); const url = accountReservationSingleUrl(config.base_url, topic);
console.log(`[AccountApi] Removing topic reservation ${url}`); console.log(`[AccountApi] Removing topic reservation ${url}`);
const headers = { const headers = {
"X-Delete-Messages": deleteMessages ? "true" : "false" "X-Delete-Messages": deleteMessages ? "true" : "false",
} };
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "DELETE", method: "DELETE",
headers: withBearerAuth(headers, session.token()) headers: withBearerAuth(headers, session.token()),
}); });
} }
@ -260,13 +264,17 @@ class AccountApi {
} }
async createBillingSubscription(tier, interval) { async createBillingSubscription(tier, interval) {
console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`); console.log(
return await this.upsertBillingSubscription("POST", tier, interval) `[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`
);
return await this.upsertBillingSubscription("POST", tier, interval);
} }
async updateBillingSubscription(tier, interval) { async updateBillingSubscription(tier, interval) {
console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`); console.log(
return await this.upsertBillingSubscription("PUT", tier, interval) `[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`
);
return await this.upsertBillingSubscription("PUT", tier, interval);
} }
async upsertBillingSubscription(method, tier, interval) { async upsertBillingSubscription(method, tier, interval) {
@ -276,8 +284,8 @@ class AccountApi {
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
tier: tier, tier: tier,
interval: interval interval: interval,
}) }),
}); });
return await response.json(); // May throw SyntaxError return await response.json(); // May throw SyntaxError
} }
@ -287,7 +295,7 @@ class AccountApi {
console.log(`[AccountApi] Cancelling billing subscription`); console.log(`[AccountApi] Cancelling billing subscription`);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "DELETE", method: "DELETE",
headers: withBearerAuth({}, session.token()) headers: withBearerAuth({}, session.token()),
}); });
} }
@ -296,7 +304,7 @@ class AccountApi {
console.log(`[AccountApi] Creating billing portal session`); console.log(`[AccountApi] Creating billing portal session`);
const response = await fetchOrThrow(url, { const response = await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBearerAuth({}, session.token()) headers: withBearerAuth({}, session.token()),
}); });
return await response.json(); // May throw SyntaxError return await response.json(); // May throw SyntaxError
} }
@ -309,21 +317,23 @@ class AccountApi {
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
number: phoneNumber, number: phoneNumber,
channel: channel channel: channel,
}) }),
}); });
} }
async addPhoneNumber(phoneNumber, code) { async addPhoneNumber(phoneNumber, code) {
const url = accountPhoneUrl(config.base_url); const url = accountPhoneUrl(config.base_url);
console.log(`[AccountApi] Adding phone number with verification code ${url}`); console.log(
`[AccountApi] Adding phone number with verification code ${url}`
);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "PUT", method: "PUT",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
number: phoneNumber, number: phoneNumber,
code: code code: code,
}) }),
}); });
} }
@ -334,8 +344,8 @@ class AccountApi {
method: "DELETE", method: "DELETE",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
number: phoneNumber number: phoneNumber,
}) }),
}); });
} }
@ -361,7 +371,10 @@ class AccountApi {
} }
} }
if (account.subscriptions) { if (account.subscriptions) {
await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations); await subscriptionManager.syncFromRemote(
account.subscriptions,
account.reservations
);
} }
return account; return account;
} catch (e) { } catch (e) {
@ -397,25 +410,25 @@ class AccountApi {
// Maps to user.Role in user/types.go // Maps to user.Role in user/types.go
export const Role = { export const Role = {
ADMIN: "admin", ADMIN: "admin",
USER: "user" USER: "user",
}; };
// Maps to server.visitorLimitBasis in server/visitor.go // Maps to server.visitorLimitBasis in server/visitor.go
export const LimitBasis = { export const LimitBasis = {
IP: "ip", IP: "ip",
TIER: "tier" TIER: "tier",
}; };
// Maps to stripe.SubscriptionStatus // Maps to stripe.SubscriptionStatus
export const SubscriptionStatus = { export const SubscriptionStatus = {
ACTIVE: "active", ACTIVE: "active",
PAST_DUE: "past_due" PAST_DUE: "past_due",
}; };
// Maps to stripe.PriceRecurringInterval // Maps to stripe.PriceRecurringInterval
export const SubscriptionInterval = { export const SubscriptionInterval = {
MONTH: "month", MONTH: "month",
YEAR: "year" YEAR: "year",
}; };
// Maps to user.Permission in user/types.go // Maps to user.Permission in user/types.go
@ -423,7 +436,7 @@ export const Permission = {
READ_WRITE: "read-write", READ_WRITE: "read-write",
READ_ONLY: "read-only", READ_ONLY: "read-only",
WRITE_ONLY: "write-only", WRITE_ONLY: "write-only",
DENY_ALL: "deny-all" DENY_ALL: "deny-all",
}; };
const accountApi = new AccountApi(); const accountApi = new AccountApi();

View file

@ -5,16 +5,16 @@ import {
topicUrl, topicUrl,
topicUrlAuth, topicUrlAuth,
topicUrlJsonPoll, topicUrlJsonPoll,
topicUrlJsonPollWithSince topicUrlJsonPollWithSince,
} from "./utils"; } from "./utils";
import userManager from "./UserManager"; import userManager from "./UserManager";
import {fetchOrThrow} from "./errors"; import { fetchOrThrow } from "./errors";
class Api { class Api {
async poll(baseUrl, topic, since) { async poll(baseUrl, topic, since) {
const user = await userManager.get(baseUrl); const user = await userManager.get(baseUrl);
const shortUrl = topicShortUrl(baseUrl, topic); const shortUrl = topicShortUrl(baseUrl, topic);
const url = (since) const url = since
? topicUrlJsonPollWithSince(baseUrl, topic, since) ? topicUrlJsonPollWithSince(baseUrl, topic, since)
: topicUrlJsonPoll(baseUrl, topic); : topicUrlJsonPoll(baseUrl, topic);
const messages = []; const messages = [];
@ -37,12 +37,12 @@ class Api {
const body = { const body = {
topic: topic, topic: topic,
message: message, message: message,
...options ...options,
}; };
await fetchOrThrow(baseUrl, { await fetchOrThrow(baseUrl, {
method: 'PUT', method: "PUT",
body: JSON.stringify(body), body: JSON.stringify(body),
headers: maybeWithAuth(headers, user) headers: maybeWithAuth(headers, user),
}); });
} }
@ -71,13 +71,19 @@ class Api {
xhr.setRequestHeader(key, value); xhr.setRequestHeader(key, value);
} }
xhr.upload.addEventListener("progress", onProgress); xhr.upload.addEventListener("progress", onProgress);
xhr.addEventListener('readystatechange', () => { xhr.addEventListener("readystatechange", () => {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) { if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response); console.log(
`[Api] Publish successful (HTTP ${xhr.status})`,
xhr.response
);
resolve(xhr.response); resolve(xhr.response);
} else if (xhr.readyState === 4) { } else if (xhr.readyState === 4) {
// Firefox bug; see description above! // Firefox bug; see description above!
console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText); console.log(
`[Api] Publish failed (HTTP ${xhr.status})`,
xhr.responseText
);
let errorText; let errorText;
try { try {
const error = JSON.parse(xhr.responseText); const error = JSON.parse(xhr.responseText);
@ -90,13 +96,13 @@ class Api {
xhr.abort(); xhr.abort();
reject(errorText ?? "An error occurred"); reject(errorText ?? "An error occurred");
} }
}) });
xhr.send(body); xhr.send(body);
}); });
send.abort = () => { send.abort = () => {
console.log(`[Api] Publish aborted by user`); console.log(`[Api] Publish aborted by user`);
xhr.abort(); xhr.abort();
} };
return send; return send;
} }
@ -104,11 +110,12 @@ class Api {
const url = topicUrlAuth(baseUrl, topic); const url = topicUrlAuth(baseUrl, topic);
console.log(`[Api] Checking auth for ${url}`); console.log(`[Api] Checking auth for ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
headers: maybeWithAuth({}, user) headers: maybeWithAuth({}, user),
}); });
if (response.status >= 200 && response.status <= 299) { if (response.status >= 200 && response.status <= 299) {
return true; return true;
} else if (response.status === 401 || response.status === 403) { // See server/server.go } else if (response.status === 401 || response.status === 403) {
// See server/server.go
return false; return false;
} }
throw new Error(`Unexpected server response ${response.status}`); throw new Error(`Unexpected server response ${response.status}`);

View file

@ -1,4 +1,10 @@
import {basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils"; import {
basicAuth,
bearerAuth,
encodeBase64Url,
topicShortUrl,
topicUrlWs,
} from "./utils";
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120]; const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
@ -9,7 +15,16 @@ const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
* Incoming messages and state changes are forwarded via listeners. * Incoming messages and state changes are forwarded via listeners.
*/ */
class Connection { class Connection {
constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) { constructor(
connectionId,
subscriptionId,
baseUrl,
topic,
user,
since,
onNotification,
onStateChanged
) {
this.connectionId = connectionId; this.connectionId = connectionId;
this.subscriptionId = subscriptionId; this.subscriptionId = subscriptionId;
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
@ -29,55 +44,78 @@ class Connection {
// we don't want to re-trigger the main view re-render potentially hundreds of times. // we don't want to re-trigger the main view re-render potentially hundreds of times.
const wsUrl = this.wsUrl(); const wsUrl = this.wsUrl();
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`); console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`
);
this.ws = new WebSocket(wsUrl); this.ws = new WebSocket(wsUrl);
this.ws.onopen = (event) => { this.ws.onopen = (event) => {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event); console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`,
event
);
this.retryCount = 0; this.retryCount = 0;
this.onStateChanged(this.subscriptionId, ConnectionState.Connected); this.onStateChanged(this.subscriptionId, ConnectionState.Connected);
} };
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`); console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`
);
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.event === 'open') { if (data.event === "open") {
return; return;
} }
const relevantAndValid = const relevantAndValid =
data.event === 'message' && data.event === "message" &&
'id' in data && "id" in data &&
'time' in data && "time" in data &&
'message' in data; "message" in data;
if (!relevantAndValid) { if (!relevantAndValid) {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`); console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`
);
return; return;
} }
this.since = data.id; this.since = data.id;
this.onNotification(this.subscriptionId, data); this.onNotification(this.subscriptionId, data);
} catch (e) { } catch (e) {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`); console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`
);
} }
}; };
this.ws.onclose = (event) => { this.ws.onclose = (event) => {
if (event.wasClean) { if (event.wasClean) {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`); console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`
);
this.ws = null; this.ws = null;
} else { } else {
const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length-1)]; const retrySeconds =
retryBackoffSeconds[
Math.min(this.retryCount, retryBackoffSeconds.length - 1)
];
this.retryCount++; this.retryCount++;
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`); console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`
);
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000); this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting); this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
} }
}; };
this.ws.onerror = (event) => { this.ws.onerror = (event) => {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event); console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`,
event
);
}; };
} }
close() { close() {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`); console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`
);
const socket = this.ws; const socket = this.ws;
const retryTimeout = this.retryTimeout; const retryTimeout = this.retryTimeout;
if (socket !== null) { if (socket !== null) {
@ -99,7 +137,7 @@ class Connection {
params.push(`auth=${this.authParam()}`); params.push(`auth=${this.authParam()}`);
} }
const wsUrl = topicUrlWs(this.baseUrl, this.topic); const wsUrl = topicUrlWs(this.baseUrl, this.topic);
return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`; return params.length === 0 ? wsUrl : `${wsUrl}?${params.join("&")}`;
} }
authParam() { authParam() {

View file

@ -1,5 +1,5 @@
import Connection from "./Connection"; import Connection from "./Connection";
import {hashCode} from "./utils"; import { hashCode } from "./utils";
/** /**
* The connection manager keeps track of active connections (WebSocket connections, see Connection). * The connection manager keeps track of active connections (WebSocket connections, see Connection).
@ -42,20 +42,25 @@ class ConnectionManager {
return; return;
} }
console.log(`[ConnectionManager] Refreshing connections`); console.log(`[ConnectionManager] Refreshing connections`);
const subscriptionsWithUsersAndConnectionId = await Promise.all(subscriptions const subscriptionsWithUsersAndConnectionId = await Promise.all(
.map(async s => { subscriptions.map(async (s) => {
const [user] = users.filter(u => u.baseUrl === s.baseUrl); const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
const connectionId = await makeConnectionId(s, user); const connectionId = await makeConnectionId(s, user);
return {...s, user, connectionId}; return { ...s, user, connectionId };
})); })
const targetIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId); );
const deletedIds = Array.from(this.connections.keys()).filter(id => !targetIds.includes(id)); const targetIds = subscriptionsWithUsersAndConnectionId.map(
(s) => s.connectionId
);
const deletedIds = Array.from(this.connections.keys()).filter(
(id) => !targetIds.includes(id)
);
// Create and add new connections // Create and add new connections
subscriptionsWithUsersAndConnectionId.forEach(subscription => { subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
const subscriptionId = subscription.id; const subscriptionId = subscription.id;
const connectionId = subscription.connectionId; const connectionId = subscription.connectionId;
const added = !this.connections.get(connectionId) const added = !this.connections.get(connectionId);
if (added) { if (added) {
const baseUrl = subscription.baseUrl; const baseUrl = subscription.baseUrl;
const topic = subscription.topic; const topic = subscription.topic;
@ -68,17 +73,22 @@ class ConnectionManager {
topic, topic,
user, user,
since, since,
(subscriptionId, notification) => this.notificationReceived(subscriptionId, notification), (subscriptionId, notification) =>
this.notificationReceived(subscriptionId, notification),
(subscriptionId, state) => this.stateChanged(subscriptionId, state) (subscriptionId, state) => this.stateChanged(subscriptionId, state)
); );
this.connections.set(connectionId, connection); this.connections.set(connectionId, connection);
console.log(`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})`); console.log(
`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${
user ? user.username : "anonymous"
})`
);
connection.start(); connection.start();
} }
}); });
// Delete old connections // Delete old connections
deletedIds.forEach(id => { deletedIds.forEach((id) => {
console.log(`[ConnectionManager] Closing connection ${id}`); console.log(`[ConnectionManager] Closing connection ${id}`);
const connection = this.connections.get(id); const connection = this.connections.get(id);
this.connections.delete(id); this.connections.delete(id);
@ -91,7 +101,10 @@ class ConnectionManager {
try { try {
this.stateListener(subscriptionId, state); this.stateListener(subscriptionId, state);
} catch (e) { } catch (e) {
console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e); console.error(
`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`,
e
);
} }
} }
} }
@ -101,17 +114,24 @@ class ConnectionManager {
try { try {
this.messageListener(subscriptionId, notification); this.messageListener(subscriptionId, notification);
} catch (e) { } catch (e) {
console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e); console.error(
`[ConnectionManager] Error handling notification for ${subscriptionId}`,
e
);
} }
} }
} }
} }
const makeConnectionId = async (subscription, user) => { const makeConnectionId = async (subscription, user) => {
return (user) return user
? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) ? hashCode(
`${subscription.id}|${user.username}|${user.password ?? ""}|${
user.token ?? ""
}`
)
: hashCode(`${subscription.id}`); : hashCode(`${subscription.id}`);
} };
const connectionManager = new ConnectionManager(); const connectionManager = new ConnectionManager();
export default connectionManager; export default connectionManager;

View file

@ -1,4 +1,11 @@
import {formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl} from "./utils"; import {
formatMessage,
formatTitleWithDefault,
openUrl,
playSound,
topicDisplayName,
topicShortUrl,
} from "./utils";
import prefs from "./Prefs"; import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager"; import subscriptionManager from "./SubscriptionManager";
import logo from "../img/ntfy.png"; import logo from "../img/ntfy.png";
@ -23,10 +30,12 @@ class Notifier {
const title = formatTitleWithDefault(notification, displayName); const title = formatTitleWithDefault(notification, displayName);
// Show notification // Show notification
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`); console.log(
`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`
);
const n = new Notification(title, { const n = new Notification(title, {
body: message, body: message,
icon: logo icon: logo,
}); });
if (notification.click) { if (notification.click) {
n.onclick = (e) => openUrl(notification.click); n.onclick = (e) => openUrl(notification.click);
@ -46,7 +55,7 @@ class Notifier {
} }
granted() { granted() {
return this.supported() && Notification.permission === 'granted'; return this.supported() && Notification.permission === "granted";
} }
maybeRequestPermission(cb) { maybeRequestPermission(cb) {
@ -56,7 +65,7 @@ class Notifier {
} }
if (!this.granted()) { if (!this.granted()) {
Notification.requestPermission().then((permission) => { Notification.requestPermission().then((permission) => {
const granted = permission === 'granted'; const granted = permission === "granted";
cb(granted); cb(granted);
}); });
} }
@ -66,7 +75,7 @@ class Notifier {
if (subscription.mutedUntil === 1) { if (subscription.mutedUntil === 1) {
return false; return false;
} }
const priority = (notification.priority) ? notification.priority : 3; const priority = notification.priority ? notification.priority : 3;
const minPriority = await prefs.minPriority(); const minPriority = await prefs.minPriority();
if (priority < minPriority) { if (priority < minPriority) {
return false; return false;
@ -79,7 +88,7 @@ class Notifier {
} }
browserSupported() { browserSupported() {
return 'Notification' in window; return "Notification" in window;
} }
/** /**
@ -87,9 +96,11 @@ class Notifier {
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
*/ */
contextSupported() { contextSupported() {
return location.protocol === 'https:' return (
|| location.hostname.match('^127.') location.protocol === "https:" ||
|| location.hostname === 'localhost'; location.hostname.match("^127.") ||
location.hostname === "localhost"
);
} }
} }

View file

@ -34,12 +34,18 @@ class Poller {
console.log(`[Poller] Polling ${subscription.id}`); console.log(`[Poller] Polling ${subscription.id}`);
const since = subscription.last; const since = subscription.last;
const notifications = await api.poll(subscription.baseUrl, subscription.topic, since); const notifications = await api.poll(
subscription.baseUrl,
subscription.topic,
since
);
if (!notifications || notifications.length === 0) { if (!notifications || notifications.length === 0) {
console.log(`[Poller] No new notifications found for ${subscription.id}`); console.log(`[Poller] No new notifications found for ${subscription.id}`);
return; return;
} }
console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); console.log(
`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`
);
await subscriptionManager.addNotifications(subscription.id, notifications); await subscriptionManager.addNotifications(subscription.id, notifications);
} }

View file

@ -2,30 +2,30 @@ import db from "./db";
class Prefs { class Prefs {
async setSound(sound) { async setSound(sound) {
db.prefs.put({key: 'sound', value: sound.toString()}); db.prefs.put({ key: "sound", value: sound.toString() });
} }
async sound() { async sound() {
const sound = await db.prefs.get('sound'); const sound = await db.prefs.get("sound");
return (sound) ? sound.value : "ding"; return sound ? sound.value : "ding";
} }
async setMinPriority(minPriority) { async setMinPriority(minPriority) {
db.prefs.put({key: 'minPriority', value: minPriority.toString()}); db.prefs.put({ key: "minPriority", value: minPriority.toString() });
} }
async minPriority() { async minPriority() {
const minPriority = await db.prefs.get('minPriority'); const minPriority = await db.prefs.get("minPriority");
return (minPriority) ? Number(minPriority.value) : 1; return minPriority ? Number(minPriority.value) : 1;
} }
async setDeleteAfter(deleteAfter) { async setDeleteAfter(deleteAfter) {
db.prefs.put({key:'deleteAfter', value: deleteAfter.toString()}); db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() });
} }
async deleteAfter() { async deleteAfter() {
const deleteAfter = await db.prefs.get('deleteAfter'); const deleteAfter = await db.prefs.get("deleteAfter");
return (deleteAfter) ? Number(deleteAfter.value) : 604800; // Default is one week return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
} }
} }

View file

@ -20,12 +20,15 @@ class Pruner {
async prune() { async prune() {
const deleteAfterSeconds = await prefs.deleteAfter(); const deleteAfterSeconds = await prefs.deleteAfter();
const pruneThresholdTimestamp = Math.round(Date.now()/1000) - deleteAfterSeconds; const pruneThresholdTimestamp =
Math.round(Date.now() / 1000) - deleteAfterSeconds;
if (deleteAfterSeconds === 0) { if (deleteAfterSeconds === 0) {
console.log(`[Pruner] Pruning is disabled. Skipping.`); console.log(`[Pruner] Pruning is disabled. Skipping.`);
return; return;
} }
console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`); console.log(
`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`
);
try { try {
await subscriptionManager.pruneNotifications(pruneThresholdTimestamp); await subscriptionManager.pruneNotifications(pruneThresholdTimestamp);
} catch (e) { } catch (e) {

View file

@ -1,20 +1,22 @@
import db from "./db"; import db from "./db";
import {topicUrl} from "./utils"; import { topicUrl } from "./utils";
class SubscriptionManager { class SubscriptionManager {
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */ /** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
async all() { async all() {
const subscriptions = await db.subscriptions.toArray(); const subscriptions = await db.subscriptions.toArray();
await Promise.all(subscriptions.map(async s => { await Promise.all(
subscriptions.map(async (s) => {
s.new = await db.notifications s.new = await db.notifications
.where({ subscriptionId: s.id, new: 1 }) .where({ subscriptionId: s.id, new: 1 })
.count(); .count();
})); })
);
return subscriptions; return subscriptions;
} }
async get(subscriptionId) { async get(subscriptionId) {
return await db.subscriptions.get(subscriptionId) return await db.subscriptions.get(subscriptionId);
} }
async add(baseUrl, topic, internal) { async add(baseUrl, topic, internal) {
@ -29,24 +31,30 @@ class SubscriptionManager {
topic: topic, topic: topic,
mutedUntil: 0, mutedUntil: 0,
last: null, last: null,
internal: internal || false internal: internal || false,
}; };
await db.subscriptions.put(subscription); await db.subscriptions.put(subscription);
return subscription; return subscription;
} }
async syncFromRemote(remoteSubscriptions, remoteReservations) { async syncFromRemote(remoteSubscriptions, remoteReservations) {
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions); console.log(
`[SubscriptionManager] Syncing subscriptions from remote`,
remoteSubscriptions
);
// Add remote subscriptions // Add remote subscriptions
let remoteIds = []; // = topicUrl(baseUrl, topic) let remoteIds = []; // = topicUrl(baseUrl, topic)
for (let i = 0; i < remoteSubscriptions.length; i++) { for (let i = 0; i < remoteSubscriptions.length; i++) {
const remote = remoteSubscriptions[i]; const remote = remoteSubscriptions[i];
const local = await this.add(remote.base_url, remote.topic, false); 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; const reservation =
remoteReservations?.find(
(r) => remote.base_url === config.base_url && remote.topic === r.topic
) || null;
await this.update(local.id, { await this.update(local.id, {
displayName: remote.display_name, // May be undefined displayName: remote.display_name, // May be undefined
reservation: reservation // May be null! reservation: reservation, // May be null!
}); });
remoteIds.push(local.id); remoteIds.push(local.id);
} }
@ -68,9 +76,7 @@ class SubscriptionManager {
async remove(subscriptionId) { async remove(subscriptionId) {
await db.subscriptions.delete(subscriptionId); await db.subscriptions.delete(subscriptionId);
await db.notifications await db.notifications.where({ subscriptionId: subscriptionId }).delete();
.where({subscriptionId: subscriptionId})
.delete();
} }
async first() { async first() {
@ -84,7 +90,7 @@ class SubscriptionManager {
return db.notifications return db.notifications
.orderBy("time") // Sort by time first .orderBy("time") // Sort by time first
.filter(n => n.subscriptionId === subscriptionId) .filter((n) => n.subscriptionId === subscriptionId)
.reverse() .reverse()
.toArray(); .toArray();
} }
@ -106,7 +112,7 @@ class SubscriptionManager {
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation 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.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {
last: notification.id last: notification.id,
}); });
} catch (e) { } catch (e) {
console.error(`[SubscriptionManager] Error adding notification`, e); console.error(`[SubscriptionManager] Error adding notification`, e);
@ -116,12 +122,13 @@ class SubscriptionManager {
/** Adds/replaces notifications, will not throw if they exist */ /** Adds/replaces notifications, will not throw if they exist */
async addNotifications(subscriptionId, notifications) { async addNotifications(subscriptionId, notifications) {
const notificationsWithSubscriptionId = notifications const notificationsWithSubscriptionId = notifications.map(
.map(notification => ({ ...notification, subscriptionId })); (notification) => ({ ...notification, subscriptionId })
);
const lastNotificationId = notifications.at(-1).id; const lastNotificationId = notifications.at(-1).id;
await db.notifications.bulkPut(notificationsWithSubscriptionId); await db.notifications.bulkPut(notificationsWithSubscriptionId);
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {
last: lastNotificationId last: lastNotificationId,
}); });
} }
@ -143,38 +150,34 @@ class SubscriptionManager {
} }
async deleteNotifications(subscriptionId) { async deleteNotifications(subscriptionId) {
await db.notifications await db.notifications.where({ subscriptionId: subscriptionId }).delete();
.where({subscriptionId: subscriptionId})
.delete();
} }
async markNotificationRead(notificationId) { async markNotificationRead(notificationId) {
await db.notifications await db.notifications.where({ id: notificationId }).modify({ new: 0 });
.where({id: notificationId})
.modify({new: 0});
} }
async markNotificationsRead(subscriptionId) { async markNotificationsRead(subscriptionId) {
await db.notifications await db.notifications
.where({subscriptionId: subscriptionId, new: 1}) .where({ subscriptionId: subscriptionId, new: 1 })
.modify({new: 0}); .modify({ new: 0 });
} }
async setMutedUntil(subscriptionId, mutedUntil) { async setMutedUntil(subscriptionId, mutedUntil) {
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {
mutedUntil: mutedUntil mutedUntil: mutedUntil,
}); });
} }
async setDisplayName(subscriptionId, displayName) { async setDisplayName(subscriptionId, displayName) {
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {
displayName: displayName displayName: displayName,
}); });
} }
async setReservation(subscriptionId, reservation) { async setReservation(subscriptionId, reservation) {
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {
reservation: reservation reservation: reservation,
}); });
} }
@ -183,9 +186,7 @@ class SubscriptionManager {
} }
async pruneNotifications(thresholdTimestamp) { async pruneNotifications(thresholdTimestamp) {
await db.notifications await db.notifications.where("time").below(thresholdTimestamp).delete();
.where("time").below(thresholdTimestamp)
.delete();
} }
} }

View file

@ -38,7 +38,7 @@ class UserManager {
return { return {
baseUrl: config.base_url, baseUrl: config.base_url,
username: session.username(), username: session.username(),
token: session.token() // Not "password"! token: session.token(), // Not "password"!
}; };
} }
} }

View file

@ -1,4 +1,4 @@
import Dexie from 'dexie'; import Dexie from "dexie";
import session from "./Session"; import session from "./Session";
// Uses Dexie.js // Uses Dexie.js
@ -8,14 +8,14 @@ import session from "./Session";
// - As per docs, we only declare the indexable columns, not all columns // - As per docs, we only declare the indexable columns, not all columns
// The IndexedDB database name is based on the logged-in user // The IndexedDB database name is based on the logged-in user
const dbName = (session.username()) ? `ntfy-${session.username()}` : "ntfy"; const dbName = session.username() ? `ntfy-${session.username()}` : "ntfy";
const db = new Dexie(dbName); const db = new Dexie(dbName);
db.version(1).stores({ db.version(1).stores({
subscriptions: '&id,baseUrl', subscriptions: "&id,baseUrl",
notifications: '&id,subscriptionId,time,new,[subscriptionId+new]', // compound key for query performance notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
users: '&baseUrl,username', users: "&baseUrl,username",
prefs: '&key' prefs: "&key",
}); });
export default db; export default db;

File diff suppressed because one or more lines are too long

View file

@ -15,7 +15,12 @@ export const throwAppError = async (response) => {
} }
const error = await maybeToJson(response); const error = await maybeToJson(response);
if (error?.code) { if (error?.code) {
console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response); console.log(
`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${
error.error || ""
}`,
response
);
if (error.code === UserExistsError.CODE) { if (error.code === UserExistsError.CODE) {
throw new UserExistsError(); throw new UserExistsError();
} else if (error.code === TopicReservedError.CODE) { } else if (error.code === TopicReservedError.CODE) {
@ -38,29 +43,38 @@ const maybeToJson = async (response) => {
} catch (e) { } catch (e) {
return null; return null;
} }
} };
export class UnauthorizedError extends Error { export class UnauthorizedError extends Error {
constructor() { super("Unauthorized"); } constructor() {
super("Unauthorized");
}
} }
export class UserExistsError extends Error { export class UserExistsError extends Error {
static CODE = 40901; // errHTTPConflictUserExists static CODE = 40901; // errHTTPConflictUserExists
constructor() { super("Username already exists"); } constructor() {
super("Username already exists");
}
} }
export class TopicReservedError extends Error { export class TopicReservedError extends Error {
static CODE = 40902; // errHTTPConflictTopicReserved static CODE = 40902; // errHTTPConflictTopicReserved
constructor() { super("Topic already reserved"); } constructor() {
super("Topic already reserved");
}
} }
export class AccountCreateLimitReachedError extends Error { export class AccountCreateLimitReachedError extends Error {
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
constructor() { super("Account creation limit reached"); } constructor() {
super("Account creation limit reached");
}
} }
export class IncorrectPasswordError extends Error { export class IncorrectPasswordError extends Error {
static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation
constructor() { super("Password incorrect"); } constructor() {
super("Password incorrect");
}
} }

View file

@ -1,4 +1,4 @@
import {rawEmojis} from "./emojis"; import { rawEmojis } from "./emojis";
import beep from "../sounds/beep.mp3"; import beep from "../sounds/beep.mp3";
import juntos from "../sounds/juntos.mp3"; import juntos from "../sounds/juntos.mp3";
import pristine from "../sounds/pristine.mp3"; import pristine from "../sounds/pristine.mp3";
@ -7,28 +7,40 @@ import dadum from "../sounds/dadum.mp3";
import pop from "../sounds/pop.mp3"; import pop from "../sounds/pop.mp3";
import popSwoosh from "../sounds/pop-swoosh.mp3"; import popSwoosh from "../sounds/pop-swoosh.mp3";
import config from "./config"; import config from "./config";
import {Base64} from 'js-base64'; import { Base64 } from "js-base64";
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`; export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws` export const topicUrlWs = (baseUrl, topic) =>
`${topicUrl(baseUrl, topic)}/ws`
.replaceAll("https://", "wss://") .replaceAll("https://", "wss://")
.replaceAll("http://", "ws://"); .replaceAll("http://", "ws://");
export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`; export const topicUrlJson = (baseUrl, topic) =>
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`; `${topicUrl(baseUrl, topic)}/json`;
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`; export const topicUrlJsonPoll = (baseUrl, topic) =>
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; `${topicUrlJson(baseUrl, topic)}?poll=1`;
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); export const topicUrlJsonPollWithSince = (baseUrl, topic, since) =>
`${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
export const topicUrlAuth = (baseUrl, topic) =>
`${topicUrl(baseUrl, topic)}/auth`;
export const topicShortUrl = (baseUrl, topic) =>
shortUrl(topicUrl(baseUrl, topic));
export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`; export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`; export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`; export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`; export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`; export const accountSubscriptionUrl = (baseUrl) =>
export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reservation`; `${baseUrl}/v1/account/subscription`;
export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`; export const accountReservationUrl = (baseUrl) =>
export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`; `${baseUrl}/v1/account/reservation`;
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; export const accountReservationSingleUrl = (baseUrl, topic) =>
`${baseUrl}/v1/account/reservation/${topic}`;
export const accountBillingSubscriptionUrl = (baseUrl) =>
`${baseUrl}/v1/account/billing/subscription`;
export const accountBillingPortalUrl = (baseUrl) =>
`${baseUrl}/v1/account/billing/portal`;
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`; export const accountPhoneVerifyUrl = (baseUrl) =>
`${baseUrl}/v1/account/phone/verify`;
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
@ -36,18 +48,18 @@ export const expandSecureUrl = (url) => `https://${url}`;
export const validUrl = (url) => { export const validUrl = (url) => {
return url.match(/^https?:\/\/.+/); return url.match(/^https?:\/\/.+/);
} };
export const validTopic = (topic) => { export const validTopic = (topic) => {
if (disallowedTopic(topic)) { if (disallowedTopic(topic)) {
return false; return false;
} }
return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app! return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
} };
export const disallowedTopic = (topic) => { export const disallowedTopic = (topic) => {
return config.disallowed_topics.includes(topic); return config.disallowed_topics.includes(topic);
} };
export const topicDisplayName = (subscription) => { export const topicDisplayName = (subscription) => {
if (subscription.displayName) { if (subscription.displayName) {
@ -60,16 +72,16 @@ export const topicDisplayName = (subscription) => {
// Format emojis (see emoji.js) // Format emojis (see emoji.js)
const emojis = {}; const emojis = {};
rawEmojis.forEach(emoji => { rawEmojis.forEach((emoji) => {
emoji.aliases.forEach(alias => { emoji.aliases.forEach((alias) => {
emojis[alias] = emoji.emoji; emojis[alias] = emoji.emoji;
}); });
}); });
const toEmojis = (tags) => { const toEmojis = (tags) => {
if (!tags) return []; if (!tags) return [];
else return tags.filter(tag => tag in emojis).map(tag => emojis[tag]); else return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]);
} };
export const formatTitleWithDefault = (m, fallback) => { export const formatTitleWithDefault = (m, fallback) => {
if (m.title) { if (m.title) {
@ -102,8 +114,8 @@ export const formatMessage = (m) => {
export const unmatchedTags = (tags) => { export const unmatchedTags = (tags) => {
if (!tags) return []; if (!tags) return [];
else return tags.filter(tag => !(tag in emojis)); else return tags.filter((tag) => !(tag in emojis));
} };
export const maybeWithAuth = (headers, user) => { export const maybeWithAuth = (headers, user) => {
if (user && user.password) { if (user && user.password) {
@ -112,52 +124,52 @@ export const maybeWithAuth = (headers, user) => {
return withBearerAuth(headers, user.token); return withBearerAuth(headers, user.token);
} }
return headers; return headers;
} };
export const maybeWithBearerAuth = (headers, token) => { export const maybeWithBearerAuth = (headers, token) => {
if (token) { if (token) {
return withBearerAuth(headers, token); return withBearerAuth(headers, token);
} }
return headers; return headers;
} };
export const withBasicAuth = (headers, username, password) => { export const withBasicAuth = (headers, username, password) => {
headers['Authorization'] = basicAuth(username, password); headers["Authorization"] = basicAuth(username, password);
return headers; return headers;
} };
export const basicAuth = (username, password) => { export const basicAuth = (username, password) => {
return `Basic ${encodeBase64(`${username}:${password}`)}`; return `Basic ${encodeBase64(`${username}:${password}`)}`;
} };
export const withBearerAuth = (headers, token) => { export const withBearerAuth = (headers, token) => {
headers['Authorization'] = bearerAuth(token); headers["Authorization"] = bearerAuth(token);
return headers; return headers;
} };
export const bearerAuth = (token) => { export const bearerAuth = (token) => {
return `Bearer ${token}`; return `Bearer ${token}`;
} };
export const encodeBase64 = (s) => { export const encodeBase64 = (s) => {
return Base64.encode(s); return Base64.encode(s);
} };
export const encodeBase64Url = (s) => { export const encodeBase64Url = (s) => {
return Base64.encodeURI(s); return Base64.encodeURI(s);
} };
export const maybeAppendActionErrors = (message, notification) => { export const maybeAppendActionErrors = (message, notification) => {
const actionErrors = (notification.actions ?? []) const actionErrors = (notification.actions ?? [])
.map(action => action.error) .map((action) => action.error)
.filter(action => !!action) .filter((action) => !!action)
.join("\n") .join("\n");
if (actionErrors.length === 0) { if (actionErrors.length === 0) {
return message; return message;
} else { } else {
return `${message}\n\n${actionErrors}`; return `${message}\n\n${actionErrors}`;
} }
} };
export const shuffle = (arr) => { export const shuffle = (arr) => {
let j, x; let j, x;
@ -168,94 +180,97 @@ export const shuffle = (arr) => {
arr[j] = x; arr[j] = x;
} }
return arr; return arr;
} };
export const splitNoEmpty = (s, delimiter) => { export const splitNoEmpty = (s, delimiter) => {
return s return s
.split(delimiter) .split(delimiter)
.map(x => x.trim()) .map((x) => x.trim())
.filter(x => x !== ""); .filter((x) => x !== "");
} };
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */ /** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
export const hashCode = async (s) => { export const hashCode = async (s) => {
let hash = 0; let hash = 0;
for (let i = 0; i < s.length; i++) { for (let i = 0; i < s.length; i++) {
const char = s.charCodeAt(i); const char = s.charCodeAt(i);
hash = ((hash<<5)-hash)+char; hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer hash = hash & hash; // Convert to 32bit integer
} }
return hash; return hash;
} };
export const formatShortDateTime = (timestamp) => { export const formatShortDateTime = (timestamp) => {
return new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'}) return new Intl.DateTimeFormat("default", {
.format(new Date(timestamp * 1000)); dateStyle: "short",
} timeStyle: "short",
}).format(new Date(timestamp * 1000));
};
export const formatShortDate = (timestamp) => { export const formatShortDate = (timestamp) => {
return new Intl.DateTimeFormat('default', {dateStyle: 'short'}) return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(
.format(new Date(timestamp * 1000)); new Date(timestamp * 1000)
} );
};
export const formatBytes = (bytes, decimals = 2) => { export const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return '0 bytes'; if (bytes === 0) return "0 bytes";
const k = 1024; const k = 1024;
const dm = decimals < 0 ? 0 : decimals; const dm = decimals < 0 ? 0 : decimals;
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
} };
export const formatNumber = (n) => { export const formatNumber = (n) => {
if (n === 0) { if (n === 0) {
return n; return n;
} else if (n % 1000 === 0) { } else if (n % 1000 === 0) {
return `${n/1000}k`; return `${n / 1000}k`;
} }
return n.toLocaleString(); return n.toLocaleString();
} };
export const formatPrice = (n) => { export const formatPrice = (n) => {
if (n % 100 === 0) { if (n % 100 === 0) {
return `$${n/100}`; return `$${n / 100}`;
} }
return `$${(n/100).toPrecision(2)}`; return `$${(n / 100).toPrecision(2)}`;
} };
export const openUrl = (url) => { export const openUrl = (url) => {
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
}; };
export const sounds = { export const sounds = {
"ding": { ding: {
file: ding, file: ding,
label: "Ding" label: "Ding",
}, },
"juntos": { juntos: {
file: juntos, file: juntos,
label: "Juntos" label: "Juntos",
}, },
"pristine": { pristine: {
file: pristine, file: pristine,
label: "Pristine" label: "Pristine",
}, },
"dadum": { dadum: {
file: dadum, file: dadum,
label: "Dadum" label: "Dadum",
}, },
"pop": { pop: {
file: pop, file: pop,
label: "Pop" label: "Pop",
}, },
"pop-swoosh": { "pop-swoosh": {
file: popSwoosh, file: popSwoosh,
label: "Pop swoosh" label: "Pop swoosh",
}, },
"beep": { beep: {
file: beep, file: beep,
label: "Beep" label: "Beep",
} },
}; };
export const playSound = async (id) => { export const playSound = async (id) => {
@ -265,13 +280,13 @@ export const playSound = async (id) => {
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
export async function* fetchLinesIterator(fileURL, headers) { export async function* fetchLinesIterator(fileURL, headers) {
const utf8Decoder = new TextDecoder('utf-8'); const utf8Decoder = new TextDecoder("utf-8");
const response = await fetch(fileURL, { const response = await fetch(fileURL, {
headers: headers headers: headers,
}); });
const reader = response.body.getReader(); const reader = response.body.getReader();
let { value: chunk, done: readerDone } = await reader.read(); let { value: chunk, done: readerDone } = await reader.read();
chunk = chunk ? utf8Decoder.decode(chunk) : ''; chunk = chunk ? utf8Decoder.decode(chunk) : "";
const re = /\n|\r|\r\n/gm; const re = /\n|\r|\r\n/gm;
let startIndex = 0; let startIndex = 0;
@ -284,7 +299,7 @@ export async function* fetchLinesIterator(fileURL, headers) {
} }
let remainder = chunk.substr(startIndex); let remainder = chunk.substr(startIndex);
({ value: chunk, done: readerDone } = await reader.read()); ({ value: chunk, done: readerDone } = await reader.read());
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : ''); chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : "");
startIndex = re.lastIndex = 0; startIndex = re.lastIndex = 0;
continue; continue;
} }
@ -297,10 +312,11 @@ export async function* fetchLinesIterator(fileURL, headers) {
} }
export const randomAlphanumericString = (len) => { export const randomAlphanumericString = (len) => {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const alphabet =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let id = ""; let id = "";
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
id += alphabet[(Math.random() * alphabet.length) | 0]; id += alphabet[(Math.random() * alphabet.length) | 0];
} }
return id; return id;
} };

File diff suppressed because it is too large Load diff

View file

@ -5,24 +5,24 @@ import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu"; import MenuIcon from "@mui/icons-material/Menu";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import * as React from "react"; import * as React from "react";
import {useState} from "react"; import { useState } from "react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import {topicDisplayName} from "../app/utils"; import { topicDisplayName } from "../app/utils";
import db from "../app/db"; import db from "../app/db";
import {useLocation, useNavigate} from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import MenuItem from '@mui/material/MenuItem'; import MenuItem from "@mui/material/MenuItem";
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsIcon from '@mui/icons-material/Notifications'; import NotificationsIcon from "@mui/icons-material/Notifications";
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff'; import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
import routes from "./routes"; import routes from "./routes";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import logo from "../img/ntfy.svg"; import logo from "../img/ntfy.svg";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider"; import Divider from "@mui/material/Divider";
import {Logout, Person, Settings} from "@mui/icons-material"; import { Logout, Person, Settings } from "@mui/icons-material";
import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemIcon from "@mui/material/ListItemIcon";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import PopupMenu from "./PopupMenu"; import PopupMenu from "./PopupMenu";
@ -40,21 +40,27 @@ const ActionBar = (props) => {
title = t("action_bar_account"); title = t("action_bar_account");
} }
return ( return (
<AppBar position="fixed" sx={{ <AppBar
width: '100%', position="fixed"
sx={{
width: "100%",
zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300) zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
ml: { sm: `${Navigation.width}px` } ml: { sm: `${Navigation.width}px` },
}}> }}
<Toolbar sx={{ >
pr: '24px', <Toolbar
background: "linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%)" sx={{
}}> pr: "24px",
background:
"linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%)",
}}
>
<IconButton <IconButton
color="inherit" color="inherit"
edge="start" edge="start"
aria-label={t("action_bar_show_menu")} aria-label={t("action_bar_show_menu")}
onClick={props.onMobileDrawerToggle} onClick={props.onMobileDrawerToggle}
sx={{ mr: 2, display: { sm: 'none' } }} sx={{ mr: 2, display: { sm: "none" } }}
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
@ -63,20 +69,21 @@ const ActionBar = (props) => {
src={logo} src={logo}
alt={t("action_bar_logo_alt")} alt={t("action_bar_logo_alt")}
sx={{ sx={{
display: { xs: 'none', sm: 'block' }, display: { xs: "none", sm: "block" },
marginRight: '10px', marginRight: "10px",
height: '28px' height: "28px",
}} }}
/> />
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}> <Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{title} {title}
</Typography> </Typography>
{props.selected && {props.selected && (
<SettingsIcons <SettingsIcons
subscription={props.selected} subscription={props.selected}
onUnsubscribe={props.onUnsubscribe} onUnsubscribe={props.onUnsubscribe}
/>} />
<ProfileIcon/> )}
<ProfileIcon />
</Toolbar> </Toolbar>
</AppBar> </AppBar>
); );
@ -88,17 +95,33 @@ const SettingsIcons = (props) => {
const subscription = props.subscription; const subscription = props.subscription;
const handleToggleMute = async () => { const handleToggleMute = async () => {
const mutedUntil = (subscription.mutedUntil) ? 0 : 1; // Make this a timestamp in the future const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil); await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
} };
return ( return (
<> <>
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} aria-label={t("action_bar_toggle_mute")}> <IconButton
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>} color="inherit"
size="large"
edge="end"
onClick={handleToggleMute}
aria-label={t("action_bar_toggle_mute")}
>
{subscription.mutedUntil ? (
<NotificationsOffIcon />
) : (
<NotificationsIcon />
)}
</IconButton> </IconButton>
<IconButton color="inherit" size="large" edge="end" onClick={(ev) => setAnchorEl(ev.currentTarget)} aria-label={t("action_bar_toggle_action_menu")}> <IconButton
<MoreVertIcon/> color="inherit"
size="large"
edge="end"
onClick={(ev) => setAnchorEl(ev.currentTarget)}
aria-label={t("action_bar_toggle_action_menu")}
>
<MoreVertIcon />
</IconButton> </IconButton>
<SubscriptionPopup <SubscriptionPopup
subscription={subscription} subscription={subscription}
@ -135,21 +158,38 @@ const ProfileIcon = () => {
return ( return (
<> <>
{session.exists() && {session.exists() && (
<IconButton color="inherit" size="large" edge="end" onClick={handleClick} aria-label={t("action_bar_profile_title")}> <IconButton
<AccountCircleIcon/> color="inherit"
size="large"
edge="end"
onClick={handleClick}
aria-label={t("action_bar_profile_title")}
>
<AccountCircleIcon />
</IconButton> </IconButton>
} )}
{!session.exists() && config.enable_login && {!session.exists() && config.enable_login && (
<Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{m: 1}} aria-label={t("action_bar_sign_in")}> <Button
color="inherit"
variant="text"
onClick={() => navigate(routes.login)}
sx={{ m: 1 }}
aria-label={t("action_bar_sign_in")}
>
{t("action_bar_sign_in")} {t("action_bar_sign_in")}
</Button> </Button>
} )}
{!session.exists() && config.enable_signup && {!session.exists() && config.enable_signup && (
<Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}> <Button
color="inherit"
variant="outlined"
onClick={() => navigate(routes.signup)}
aria-label={t("action_bar_sign_up")}
>
{t("action_bar_sign_up")} {t("action_bar_sign_up")}
</Button> </Button>
} )}
<PopupMenu <PopupMenu
horizontal="right" horizontal="right"
anchorEl={anchorEl} anchorEl={anchorEl}

View file

@ -1,27 +1,43 @@
import * as React from 'react'; import * as React from "react";
import {createContext, Suspense, useContext, useEffect, useState} from 'react'; import {
import Box from '@mui/material/Box'; createContext,
import {ThemeProvider} from '@mui/material/styles'; Suspense,
import CssBaseline from '@mui/material/CssBaseline'; useContext,
import Toolbar from '@mui/material/Toolbar'; useEffect,
import {AllSubscriptions, SingleSubscription} from "./Notifications"; useState,
} from "react";
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 { AllSubscriptions, SingleSubscription } from "./Notifications";
import theme from "./theme"; import theme from "./theme";
import Navigation from "./Navigation"; import Navigation from "./Navigation";
import ActionBar from "./ActionBar"; import ActionBar from "./ActionBar";
import notifier from "../app/Notifier"; import notifier from "../app/Notifier";
import Preferences from "./Preferences"; import Preferences from "./Preferences";
import {useLiveQuery} from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import {BrowserRouter, Outlet, Route, Routes, useParams} from "react-router-dom"; import {
import {expandUrl} from "../app/utils"; BrowserRouter,
Outlet,
Route,
Routes,
useParams,
} from "react-router-dom";
import { expandUrl } from "../app/utils";
import ErrorBoundary from "./ErrorBoundary"; import ErrorBoundary from "./ErrorBoundary";
import routes from "./routes"; import routes from "./routes";
import {useAccountListener, useBackgroundProcesses, useConnectionListeners} from "./hooks"; import {
useAccountListener,
useBackgroundProcesses,
useConnectionListeners,
} from "./hooks";
import PublishDialog from "./PublishDialog"; import PublishDialog from "./PublishDialog";
import Messaging from "./Messaging"; import Messaging from "./Messaging";
import "./i18n"; // Translations! import "./i18n"; // Translations!
import {Backdrop, CircularProgress} from "@mui/material"; import { Backdrop, CircularProgress } from "@mui/material";
import Login from "./Login"; import Login from "./Login";
import Signup from "./Signup"; import Signup from "./Signup";
import Account from "./Account"; import Account from "./Account";
@ -35,17 +51,23 @@ const App = () => {
<BrowserRouter> <BrowserRouter>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<AccountContext.Provider value={{ account, setAccount }}> <AccountContext.Provider value={{ account, setAccount }}>
<CssBaseline/> <CssBaseline />
<ErrorBoundary> <ErrorBoundary>
<Routes> <Routes>
<Route path={routes.login} element={<Login/>}/> <Route path={routes.login} element={<Login />} />
<Route path={routes.signup} element={<Signup/>}/> <Route path={routes.signup} element={<Signup />} />
<Route element={<Layout/>}> <Route element={<Layout />}>
<Route path={routes.app} element={<AllSubscriptions/>}/> <Route path={routes.app} element={<AllSubscriptions />} />
<Route path={routes.account} element={<Account/>}/> <Route path={routes.account} element={<Account />} />
<Route path={routes.settings} element={<Preferences/>}/> <Route path={routes.settings} element={<Preferences />} />
<Route path={routes.subscription} element={<SingleSubscription/>}/> <Route
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/> path={routes.subscription}
element={<SingleSubscription />}
/>
<Route
path={routes.subscriptionExternal}
element={<SingleSubscription />}
/>
</Route> </Route>
</Routes> </Routes>
</ErrorBoundary> </ErrorBoundary>
@ -54,30 +76,39 @@ const App = () => {
</BrowserRouter> </BrowserRouter>
</Suspense> </Suspense>
); );
} };
const Layout = () => { const Layout = () => {
const params = useParams(); const params = useParams();
const { account, setAccount } = useContext(AccountContext); const { account, setAccount } = useContext(AccountContext);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted()); const [notificationsGranted, setNotificationsGranted] = useState(
notifier.granted()
);
const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all()); const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all()); const subscriptions = useLiveQuery(() => subscriptionManager.all());
const subscriptionsWithoutInternal = subscriptions?.filter(s => !s.internal); const subscriptionsWithoutInternal = subscriptions?.filter(
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; (s) => !s.internal
const [selected] = (subscriptionsWithoutInternal || []).filter(s => { );
return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) const newNotificationsCount =
|| (config.base_url === s.baseUrl && params.topic === s.topic) subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
const [selected] = (subscriptionsWithoutInternal || []).filter((s) => {
return (
(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); useConnectionListeners(account, subscriptions, users);
useAccountListener(setAccount) useAccountListener(setAccount);
useBackgroundProcesses(); useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
return ( return (
<Box sx={{display: 'flex'}}> <Box sx={{ display: "flex" }}>
<ActionBar <ActionBar
selected={selected} selected={selected}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
@ -89,14 +120,18 @@ const Layout = () => {
mobileDrawerOpen={mobileDrawerOpen} mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onNotificationGranted={setNotificationsGranted} onNotificationGranted={setNotificationsGranted}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} onPublishMessageClick={() =>
setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)
}
/> />
<Main> <Main>
<Toolbar/> <Toolbar />
<Outlet context={{ <Outlet
context={{
subscriptions: subscriptionsWithoutInternal, subscriptions: subscriptionsWithoutInternal,
selected: selected selected: selected,
}}/> }}
/>
</Main> </Main>
<Messaging <Messaging
selected={selected} selected={selected}
@ -105,7 +140,7 @@ const Layout = () => {
/> />
</Box> </Box>
); );
} };
const Main = (props) => { const Main = (props) => {
return ( return (
@ -113,14 +148,17 @@ const Main = (props) => {
id="main" id="main"
component="main" component="main"
sx={{ sx={{
display: 'flex', display: "flex",
flexGrow: 1, flexGrow: 1,
flexDirection: 'column', flexDirection: "column",
padding: 3, padding: 3,
width: {sm: `calc(100% - ${Navigation.width}px)`}, width: { sm: `calc(100% - ${Navigation.width}px)` },
height: '100vh', height: "100vh",
overflow: 'auto', overflow: "auto",
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}} }}
> >
{props.children} {props.children}
@ -133,7 +171,10 @@ const Loader = () => (
open={true} open={true}
sx={{ sx={{
zIndex: 100000, zIndex: 100000,
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}} }}
> >
<CircularProgress color="success" disableShrink /> <CircularProgress color="success" disableShrink />
@ -141,7 +182,8 @@ const Loader = () => (
); );
const updateTitle = (newNotificationsCount) => { const updateTitle = (newNotificationsCount) => {
document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy"; document.title =
} newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
};
export default App; export default App;

View file

@ -5,7 +5,7 @@ import fileImage from "../img/file-image.svg";
import fileVideo from "../img/file-video.svg"; import fileVideo from "../img/file-video.svg";
import fileAudio from "../img/file-audio.svg"; import fileAudio from "../img/file-audio.svg";
import fileApp from "../img/file-app.svg"; import fileApp from "../img/file-app.svg";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
const AttachmentIcon = (props) => { const AttachmentIcon = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -14,13 +14,13 @@ const AttachmentIcon = (props) => {
if (!type) { if (!type) {
imageFile = fileDocument; imageFile = fileDocument;
imageLabel = t("notifications_attachment_file_image"); imageLabel = t("notifications_attachment_file_image");
} else if (type.startsWith('image/')) { } else if (type.startsWith("image/")) {
imageFile = fileImage; imageFile = fileImage;
imageLabel = t("notifications_attachment_file_video"); imageLabel = t("notifications_attachment_file_video");
} else if (type.startsWith('video/')) { } else if (type.startsWith("video/")) {
imageFile = fileVideo; imageFile = fileVideo;
imageLabel = t("notifications_attachment_file_video"); imageLabel = t("notifications_attachment_file_video");
} else if (type.startsWith('audio/')) { } else if (type.startsWith("audio/")) {
imageFile = fileAudio; imageFile = fileAudio;
imageLabel = t("notifications_attachment_file_audio"); imageLabel = t("notifications_attachment_file_audio");
} else if (type === "application/vnd.android.package-archive") { } else if (type === "application/vnd.android.package-archive") {
@ -37,11 +37,11 @@ const AttachmentIcon = (props) => {
alt={imageLabel} alt={imageLabel}
loading="lazy" loading="lazy"
sx={{ sx={{
width: '28px', width: "28px",
height: '28px' height: "28px",
}} }}
/> />
); );
} };
export default AttachmentIcon; export default AttachmentIcon;

View file

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from "react";
import {Avatar} from "@mui/material"; import { Avatar } from "@mui/material";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import logo from "../img/ntfy-filled.svg"; import logo from "../img/ntfy-filled.svg";
@ -7,13 +7,13 @@ const AvatarBox = (props) => {
return ( return (
<Box <Box
sx={{ sx={{
display: 'flex', display: "flex",
flexGrow: 1, flexGrow: 1,
justifyContent: 'center', justifyContent: "center",
flexDirection: 'column', flexDirection: "column",
alignContent: 'center', alignContent: "center",
alignItems: 'center', alignItems: "center",
height: '100vh' height: "100vh",
}} }}
> >
<Avatar <Avatar
@ -24,6 +24,6 @@ const AvatarBox = (props) => {
{props.children} {props.children}
</Box> </Box>
); );
} };
export default AvatarBox; export default AvatarBox;

View file

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

View file

@ -1,15 +1,15 @@
import * as React from 'react'; import * as React from "react";
import {useRef, useState} from 'react'; import { useRef, useState } from "react";
import Typography from '@mui/material/Typography'; import Typography from "@mui/material/Typography";
import {rawEmojis} from '../app/emojis'; import { rawEmojis } from "../app/emojis";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import {ClickAwayListener, Fade, InputAdornment, styled} from "@mui/material"; import { ClickAwayListener, Fade, InputAdornment, styled } from "@mui/material";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import {Close} from "@mui/icons-material"; import { Close } from "@mui/icons-material";
import Popper from "@mui/material/Popper"; import Popper from "@mui/material/Popper";
import {splitNoEmpty} from "../app/utils"; import { splitNoEmpty } from "../app/utils";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
// Create emoji list by category and create a search base (string with all search words) // Create emoji list by category and create a search base (string with all search words)
// //
@ -17,17 +17,21 @@ import {useTranslation} from "react-i18next";
// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported. // This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported.
const emojisByCategory = {}; const emojisByCategory = {};
const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent); const isDesktopChrome =
/Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);
const maxSupportedVersionForDesktopChrome = 11; const maxSupportedVersionForDesktopChrome = 11;
rawEmojis.forEach(emoji => { rawEmojis.forEach((emoji) => {
if (!emojisByCategory[emoji.category]) { if (!emojisByCategory[emoji.category]) {
emojisByCategory[emoji.category] = []; emojisByCategory[emoji.category] = [];
} }
try { try {
const unicodeVersion = parseFloat(emoji.unicode_version); const unicodeVersion = parseFloat(emoji.unicode_version);
const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome; const supportedEmoji =
unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
if (supportedEmoji) { if (supportedEmoji) {
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`; const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(
" "
)} ${emoji.tags.join(" ")}`;
const emojiWithSearchBase = { ...emoji, searchBase: searchBase }; const emojiWithSearchBase = { ...emoji, searchBase: searchBase };
emojisByCategory[emoji.category].push(emojiWithSearchBase); emojisByCategory[emoji.category].push(emojiWithSearchBase);
} }
@ -59,42 +63,60 @@ const EmojiPicker = (props) => {
{({ TransitionProps }) => ( {({ TransitionProps }) => (
<ClickAwayListener onClickAway={props.onClose}> <ClickAwayListener onClickAway={props.onClose}>
<Fade {...TransitionProps} timeout={350}> <Fade {...TransitionProps} timeout={350}>
<Box sx={{ <Box
sx={{
boxShadow: 3, boxShadow: 3,
padding: 2, padding: 2,
paddingRight: 0, paddingRight: 0,
paddingBottom: 1, paddingBottom: 1,
width: "380px", width: "380px",
maxHeight: "300px", maxHeight: "300px",
backgroundColor: 'background.paper', backgroundColor: "background.paper",
overflowY: "scroll" overflowY: "scroll",
}}> }}
>
<TextField <TextField
inputRef={searchRef} inputRef={searchRef}
margin="dense" margin="dense"
size="small" size="small"
placeholder={t("emoji_picker_search_placeholder")} placeholder={t("emoji_picker_search_placeholder")}
value={search} value={search}
onChange={ev => setSearch(ev.target.value)} onChange={(ev) => setSearch(ev.target.value)}
type="text" type="text"
variant="standard" variant="standard"
fullWidth fullWidth
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }} sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
inputProps={{ inputProps={{
role: "searchbox", role: "searchbox",
"aria-label": t("emoji_picker_search_placeholder") "aria-label": t("emoji_picker_search_placeholder"),
}} }}
InputProps={{ InputProps={{
endAdornment: endAdornment: (
<InputAdornment position="end" sx={{ display: (search) ? '' : 'none' }}> <InputAdornment
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}> position="end"
<Close/> sx={{ display: search ? "" : "none" }}
>
<IconButton
size="small"
onClick={handleSearchClear}
edge="end"
aria-label={t("emoji_picker_search_clear")}
>
<Close />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
),
}} }}
/> />
<Box sx={{ display: "flex", flexWrap: "wrap", paddingRight: 0, marginTop: 1 }}> <Box
{Object.keys(emojisByCategory).map(category => sx={{
display: "flex",
flexWrap: "wrap",
paddingRight: 0,
marginTop: 1,
}}
>
{Object.keys(emojisByCategory).map((category) => (
<Category <Category
key={category} key={category}
title={category} title={category}
@ -102,7 +124,7 @@ const EmojiPicker = (props) => {
search={searchFields} search={searchFields}
onPick={props.onEmojiPick} onPick={props.onEmojiPick}
/> />
)} ))}
</Box> </Box>
</Box> </Box>
</Fade> </Fade>
@ -116,19 +138,19 @@ const Category = (props) => {
const showTitle = props.search.length === 0; const showTitle = props.search.length === 0;
return ( return (
<> <>
{showTitle && {showTitle && (
<Typography variant="body1" sx={{ width: "100%", marginBottom: 1 }}> <Typography variant="body1" sx={{ width: "100%", marginBottom: 1 }}>
{props.title} {props.title}
</Typography> </Typography>
} )}
{props.emojis.map(emoji => {props.emojis.map((emoji) => (
<Emoji <Emoji
key={emoji.aliases[0]} key={emoji.aliases[0]}
emoji={emoji} emoji={emoji}
search={props.search} search={props.search}
onClick={() => props.onPick(emoji.aliases[0])} onClick={() => props.onPick(emoji.aliases[0])}
/> />
)} ))}
</> </>
); );
}; };
@ -142,7 +164,7 @@ const Emoji = (props) => {
onClick={props.onClick} onClick={props.onClick}
title={title} title={title}
aria-label={title} aria-label={title}
style={{ display: (matches) ? '' : 'none' }} style={{ display: matches ? "" : "none" }}
> >
{props.emoji.emoji} {props.emoji.emoji}
</EmojiDiv> </EmojiDiv>
@ -160,8 +182,8 @@ const EmojiDiv = styled("div")({
cursor: "pointer", cursor: "pointer",
opacity: 0.85, opacity: 0.85,
"&:hover": { "&:hover": {
opacity: 1 opacity: 1,
} },
}); });
const emojiMatches = (emoji, words) => { const emojiMatches = (emoji, words) => {
@ -174,6 +196,6 @@ const emojiMatches = (emoji, words) => {
} }
} }
return true; return true;
} };
export default EmojiPicker; export default EmojiPicker;

View file

@ -1,8 +1,8 @@
import * as React from "react"; import * as React from "react";
import StackTrace from "stacktrace-js"; import StackTrace from "stacktrace-js";
import {CircularProgress, Link} from "@mui/material"; import { CircularProgress, Link } from "@mui/material";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import {Trans, withTranslation} from "react-i18next"; import { Trans, withTranslation } from "react-i18next";
class ErrorBoundaryImpl extends React.Component { class ErrorBoundaryImpl extends React.Component {
constructor(props) { constructor(props) {
@ -11,7 +11,7 @@ class ErrorBoundaryImpl extends React.Component {
error: false, error: false,
originalStack: null, originalStack: null,
niceStack: null, niceStack: null,
unsupportedIndexedDB: false unsupportedIndexedDB: false,
}; };
} }
@ -21,8 +21,10 @@ class ErrorBoundaryImpl extends React.Component {
// Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see // Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see
// - https://github.com/dexie/Dexie.js/issues/312 // - https://github.com/dexie/Dexie.js/issues/312
// - https://bugzilla.mozilla.org/show_bug.cgi?id=781982 // - https://bugzilla.mozilla.org/show_bug.cgi?id=781982
const isUnsupportedIndexedDB = error?.name === "InvalidStateError" || const isUnsupportedIndexedDB =
(error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1); error?.name === "InvalidStateError" ||
(error?.name === "DatabaseClosedError" &&
error?.message?.indexOf("InvalidStateError") !== -1);
if (isUnsupportedIndexedDB) { if (isUnsupportedIndexedDB) {
this.handleUnsupportedIndexedDB(); this.handleUnsupportedIndexedDB();
@ -36,17 +38,24 @@ class ErrorBoundaryImpl extends React.Component {
const prettierOriginalStack = info.componentStack const prettierOriginalStack = info.componentStack
.trim() .trim()
.split("\n") .split("\n")
.map(line => ` at ${line}`) .map((line) => ` at ${line}`)
.join("\n"); .join("\n");
this.setState({ this.setState({
error: true, error: true,
originalStack: `${error.toString()}\n${prettierOriginalStack}` originalStack: `${error.toString()}\n${prettierOriginalStack}`,
}); });
// Fetch additional info and a better stack trace // Fetch additional info and a better stack trace
StackTrace.fromError(error).then(stack => { StackTrace.fromError(error).then((stack) => {
console.error("[ErrorBoundary] Stacktrace fetched", 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 }); this.setState({ niceStack });
}); });
} }
@ -54,7 +63,7 @@ class ErrorBoundaryImpl extends React.Component {
handleUnsupportedIndexedDB() { handleUnsupportedIndexedDB() {
this.setState({ this.setState({
error: true, error: true,
unsupportedIndexedDB: true unsupportedIndexedDB: true,
}); });
} }
@ -81,15 +90,17 @@ class ErrorBoundaryImpl extends React.Component {
renderUnsupportedIndexedDB() { renderUnsupportedIndexedDB() {
const { t } = this.props; const { t } = this.props;
return ( return (
<div style={{margin: '20px'}}> <div style={{ margin: "20px" }}>
<h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2> <h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2>
<p style={{maxWidth: "600px"}}> <p style={{ maxWidth: "600px" }}>
<Trans <Trans
i18nKey="error_boundary_unsupported_indexeddb_description" i18nKey="error_boundary_unsupported_indexeddb_description"
components={{ components={{
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues/208"/>, githubLink: (
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>, <Link href="https://github.com/binwiederhier/ntfy/issues/208" />
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/> ),
discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />,
}} }}
/> />
</p> </p>
@ -100,25 +111,37 @@ class ErrorBoundaryImpl extends React.Component {
renderError() { renderError() {
const { t } = this.props; const { t } = this.props;
return ( return (
<div style={{margin: '20px'}}> <div style={{ margin: "20px" }}>
<h2>{t("error_boundary_title")} 😮</h2> <h2>{t("error_boundary_title")} 😮</h2>
<p> <p>
<Trans <Trans
i18nKey="error_boundary_description" i18nKey="error_boundary_description"
components={{ components={{
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues"/>, githubLink: (
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>, <Link href="https://github.com/binwiederhier/ntfy/issues" />
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/> ),
discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />,
}} }}
/> />
</p> </p>
<p> <p>
<Button variant="outlined" onClick={() => this.copyStack()}>{t("error_boundary_button_copy_stack_trace")}</Button> <Button variant="outlined" onClick={() => this.copyStack()}>
{t("error_boundary_button_copy_stack_trace")}
</Button>
</p> </p>
<h3>{t("error_boundary_stack_trace")}</h3> <h3>{t("error_boundary_stack_trace")}</h3>
{this.state.niceStack {this.state.niceStack ? (
? <pre>{this.state.niceStack}</pre> <pre>{this.state.niceStack}</pre>
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> {t("error_boundary_gathering_info")}</>} ) : (
<>
<CircularProgress
size="20px"
sx={{ verticalAlign: "text-bottom" }}
/>{" "}
{t("error_boundary_gathering_info")}
</>
)}
<pre>{this.state.originalStack}</pre> <pre>{this.state.originalStack}</pre>
</div> </div>
); );

View file

@ -1,20 +1,20 @@
import * as React from 'react'; import * as React from "react";
import {useState} from 'react'; import { useState } from "react";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import routes from "./routes"; import routes from "./routes";
import session from "../app/Session"; import session from "../app/Session";
import {NavLink} from "react-router-dom"; import { NavLink } from "react-router-dom";
import AvatarBox from "./AvatarBox"; import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import {InputAdornment} from "@mui/material"; import { InputAdornment } from "@mui/material";
import {Visibility, VisibilityOff} from "@mui/icons-material"; import { Visibility, VisibilityOff } from "@mui/icons-material";
import {UnauthorizedError} from "../app/errors"; import { UnauthorizedError } from "../app/errors";
const Login = () => { const Login = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -28,7 +28,9 @@ const Login = () => {
const user = { username, password }; const user = { username, password };
try { try {
const token = await accountApi.login(user); const token = await accountApi.login(user);
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); console.log(
`[Login] User auth for user ${user.username} successful, token is ${token}`
);
session.store(user.username, token); session.store(user.username, token);
window.location.href = routes.app; window.location.href = routes.app;
} catch (e) { } catch (e) {
@ -43,16 +45,19 @@ const Login = () => {
if (!config.enable_login) { if (!config.enable_login) {
return ( return (
<AvatarBox> <AvatarBox>
<Typography sx={{ typography: 'h6' }}>{t("login_disabled")}</Typography> <Typography sx={{ typography: "h6" }}>{t("login_disabled")}</Typography>
</AvatarBox> </AvatarBox>
); );
} }
return ( return (
<AvatarBox> <AvatarBox>
<Typography sx={{ typography: 'h6' }}> <Typography sx={{ typography: "h6" }}>{t("login_title")}</Typography>
{t("login_title")} <Box
</Typography> component="form"
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}> onSubmit={handleSubmit}
noValidate
sx={{ mt: 1, maxWidth: 400 }}
>
<TextField <TextField
margin="dense" margin="dense"
required required
@ -61,7 +66,7 @@ const Login = () => {
label={t("signup_form_username")} label={t("signup_form_username")}
name="username" name="username"
value={username} value={username}
onChange={ev => setUsername(ev.target.value.trim())} onChange={(ev) => setUsername(ev.target.value.trim())}
autoFocus autoFocus
/> />
<TextField <TextField
@ -73,7 +78,7 @@ const Login = () => {
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
id="password" id="password"
value={password} value={password}
onChange={ev => setPassword(ev.target.value.trim())} onChange={(ev) => setPassword(ev.target.value.trim())}
autoComplete="current-password" autoComplete="current-password"
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
@ -87,7 +92,7 @@ const Login = () => {
{showPassword ? <VisibilityOff /> : <Visibility />} {showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
) ),
}} }}
/> />
<Button <Button
@ -95,28 +100,36 @@ const Login = () => {
fullWidth fullWidth
variant="contained" variant="contained"
disabled={username === "" || password === ""} disabled={username === "" || password === ""}
sx={{mt: 2, mb: 2}} sx={{ mt: 2, mb: 2 }}
> >
{t("login_form_button_submit")} {t("login_form_button_submit")}
</Button> </Button>
{error && {error && (
<Box sx={{ <Box
sx={{
mb: 1, mb: 1,
display: 'flex', display: "flex",
flexGrow: 1, flexGrow: 1,
justifyContent: 'center', justifyContent: "center",
}}> }}
<WarningAmberIcon color="error" sx={{mr: 1}}/> >
<Typography sx={{color: 'error.main'}}>{error}</Typography> <WarningAmberIcon color="error" sx={{ mr: 1 }} />
<Typography sx={{ color: "error.main" }}>{error}</Typography>
</Box> </Box>
} )}
<Box sx={{width: "100%"}}> <Box sx={{ width: "100%" }}>
{/* This is where the password reset link would go */} {/* This is where the password reset link would go */}
{config.enable_signup && <div style={{float: "right"}}><NavLink to={routes.signup} variant="body1">{t("login_link_signup")}</NavLink></div>} {config.enable_signup && (
<div style={{ float: "right" }}>
<NavLink to={routes.signup} variant="body1">
{t("login_link_signup")}
</NavLink>
</div>
)}
</Box> </Box>
</Box> </Box>
</AvatarBox> </AvatarBox>
); );
} };
export default Login; export default Login;

View file

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from "react";
import {useState} from 'react'; import { useState } from "react";
import Navigation from "./Navigation"; import Navigation from "./Navigation";
import Paper from "@mui/material/Paper"; import Paper from "@mui/material/Paper";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@ -7,9 +7,9 @@ import TextField from "@mui/material/TextField";
import SendIcon from "@mui/icons-material/Send"; import SendIcon from "@mui/icons-material/Send";
import api from "../app/Api"; import api from "../app/Api";
import PublishDialog from "./PublishDialog"; import PublishDialog from "./PublishDialog";
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import {Portal, Snackbar} from "@mui/material"; import { Portal, Snackbar } from "@mui/material";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
const Messaging = (props) => { const Messaging = (props) => {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
@ -24,17 +24,19 @@ const Messaging = (props) => {
const handleDialogClose = () => { const handleDialogClose = () => {
props.onDialogOpenModeChange(""); props.onDialogOpenModeChange("");
setDialogKey(prev => prev+1); setDialogKey((prev) => prev + 1);
}; };
return ( return (
<> <>
{subscription && <MessageBar {subscription && (
<MessageBar
subscription={subscription} subscription={subscription}
message={message} message={message}
onMessageChange={setMessage} onMessageChange={setMessage}
onOpenDialogClick={handleOpenDialogClick} onOpenDialogClick={handleOpenDialogClick}
/>} />
)}
<PublishDialog <PublishDialog
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
openMode={dialogOpenMode} openMode={dialogOpenMode}
@ -42,12 +44,18 @@ const Messaging = (props) => {
topic={subscription?.topic ?? ""} topic={subscription?.topic ?? ""}
message={message} message={message}
onClose={handleDialogClose} onClose={handleDialogClose}
onDragEnter={() => props.onDialogOpenModeChange(prev => (prev) ? prev : PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open onDragEnter={() =>
onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)} props.onDialogOpenModeChange((prev) =>
prev ? prev : PublishDialog.OPEN_MODE_DRAG
)
} // Only update if not already open
onResetOpenMode={() =>
props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)
}
/> />
</> </>
); );
} };
const MessageBar = (props) => { const MessageBar = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -55,7 +63,11 @@ const MessageBar = (props) => {
const [snackOpen, setSnackOpen] = useState(false); const [snackOpen, setSnackOpen] = useState(false);
const handleSendClick = async () => { const handleSendClick = async () => {
try { try {
await api.publish(subscription.baseUrl, subscription.topic, props.message); await api.publish(
subscription.baseUrl,
subscription.topic,
props.message
);
} catch (e) { } catch (e) {
console.log(`[MessageBar] Error publishing message`, e); console.log(`[MessageBar] Error publishing message`, e);
setSnackOpen(true); setSnackOpen(true);
@ -67,16 +79,25 @@ const MessageBar = (props) => {
elevation={3} elevation={3}
sx={{ sx={{
display: "flex", display: "flex",
position: 'fixed', position: "fixed",
bottom: 0, bottom: 0,
right: 0, right: 0,
padding: 2, padding: 2,
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` }, width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}} }}
> >
<IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick} aria-label={t("message_bar_show_dialog")}> <IconButton
<KeyboardArrowUpIcon/> color="inherit"
size="large"
edge="start"
onClick={props.onOpenDialogClick}
aria-label={t("message_bar_show_dialog")}
>
<KeyboardArrowUpIcon />
</IconButton> </IconButton>
<TextField <TextField
autoFocus autoFocus
@ -88,16 +109,22 @@ const MessageBar = (props) => {
fullWidth fullWidth
variant="standard" variant="standard"
value={props.message} value={props.message}
onChange={ev => props.onMessageChange(ev.target.value)} onChange={(ev) => props.onMessageChange(ev.target.value)}
onKeyPress={(ev) => { onKeyPress={(ev) => {
if (ev.key === 'Enter') { if (ev.key === "Enter") {
ev.preventDefault(); ev.preventDefault();
handleSendClick(); handleSendClick();
} }
}} }}
/> />
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick} aria-label={t("message_bar_publish")}> <IconButton
<SendIcon/> color="inherit"
size="large"
edge="end"
onClick={handleSendClick}
aria-label={t("message_bar_publish")}
>
<SendIcon />
</IconButton> </IconButton>
<Portal> <Portal>
<Snackbar <Snackbar

View file

@ -1,6 +1,6 @@
import Drawer from "@mui/material/Drawer"; import Drawer from "@mui/material/Drawer";
import * as React from "react"; import * as React from "react";
import {useContext, useState} from "react"; import { useContext, useState } from "react";
import ListItemButton from "@mui/material/ListItemButton"; import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemIcon from "@mui/material/ListItemIcon";
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
@ -12,38 +12,57 @@ import List from "@mui/material/List";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import SubscribeDialog from "./SubscribeDialog"; import SubscribeDialog from "./SubscribeDialog";
import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Portal, Tooltip} from "@mui/material"; import {
Alert,
AlertTitle,
Badge,
CircularProgress,
Link,
ListSubheader,
Portal,
Tooltip,
} from "@mui/material";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import {openUrl, topicDisplayName, topicUrl} from "../app/utils"; import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
import routes from "./routes"; import routes from "./routes";
import {ConnectionState} from "../app/Connection"; import { ConnectionState } from "../app/Connection";
import {useLocation, useNavigate} from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import {ChatBubble, MoreVert, NotificationsOffOutlined, Send} from "@mui/icons-material"; import {
ChatBubble,
MoreVert,
NotificationsOffOutlined,
Send,
} from "@mui/icons-material";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import notifier from "../app/Notifier"; import notifier from "../app/Notifier";
import config from "../app/config"; import config from "../app/config";
import ArticleIcon from '@mui/icons-material/Article'; import ArticleIcon from "@mui/icons-material/Article";
import {Trans, useTranslation} from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import accountApi, {Permission, Role} from "../app/AccountApi"; import accountApi, { Permission, Role } from "../app/AccountApi";
import CelebrationIcon from '@mui/icons-material/Celebration'; import CelebrationIcon from "@mui/icons-material/Celebration";
import UpgradeDialog from "./UpgradeDialog"; import UpgradeDialog from "./UpgradeDialog";
import {AccountContext} from "./App"; import { AccountContext } from "./App";
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; import {
PermissionDenyAll,
PermissionRead,
PermissionReadWrite,
PermissionWrite,
} from "./ReserveIcons";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { SubscriptionPopup } from "./SubscriptionPopup"; import { SubscriptionPopup } from "./SubscriptionPopup";
const navWidth = 280; const navWidth = 280;
const Navigation = (props) => { const Navigation = (props) => {
const navigationList = <NavList {...props}/>; const navigationList = <NavList {...props} />;
return ( return (
<Box <Box
component="nav" component="nav"
role="navigation" role="navigation"
sx={{width: {sm: Navigation.width}, flexShrink: {sm: 0}}} sx={{ width: { sm: Navigation.width }, flexShrink: { sm: 0 } }}
> >
{/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */} {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
<Drawer <Drawer
@ -53,8 +72,8 @@ const Navigation = (props) => {
onClose={props.onMobileDrawerToggle} onClose={props.onMobileDrawerToggle}
ModalProps={{ keepMounted: true }} // Better open performance on mobile. ModalProps={{ keepMounted: true }} // Better open performance on mobile.
sx={{ sx={{
display: { xs: 'block', sm: 'none' }, display: { xs: "block", sm: "none" },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth }, "& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
}} }}
> >
{navigationList} {navigationList}
@ -65,8 +84,8 @@ const Navigation = (props) => {
variant="permanent" variant="permanent"
role="menubar" role="menubar"
sx={{ sx={{
display: { xs: 'none', sm: 'block' }, display: { xs: "none", sm: "block" },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth }, "& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
}} }}
> >
{navigationList} {navigationList}
@ -86,18 +105,23 @@ const NavList = (props) => {
const handleSubscribeReset = () => { const handleSubscribeReset = () => {
setSubscribeDialogOpen(false); setSubscribeDialogOpen(false);
setSubscribeDialogKey(prev => prev+1); setSubscribeDialogKey((prev) => prev + 1);
} };
const handleSubscribeSubmit = (subscription) => { const handleSubscribeSubmit = (subscription) => {
console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); console.log(
`[Navigation] New subscription: ${subscription.id}`,
subscription
);
handleSubscribeReset(); handleSubscribeReset();
navigate(routes.forSubscription(subscription)); navigate(routes.forSubscription(subscription));
handleRequestNotificationPermission(); handleRequestNotificationPermission();
} };
const handleRequestNotificationPermission = () => { const handleRequestNotificationPermission = () => {
notifier.maybeRequestPermission(granted => props.onNotificationGranted(granted)) notifier.maybeRequestPermission((granted) =>
props.onNotificationGranted(granted)
);
}; };
const handleAccountClick = () => { const handleAccountClick = () => {
@ -110,60 +134,103 @@ const NavList = (props) => {
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid; const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
const showSubscriptionsList = props.subscriptions?.length > 0; const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser const showNotificationContextNotSupportedBox =
const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted; notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
const navListPadding = (showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox) ? '0' : ''; const showNotificationGrantBox =
notifier.supported() &&
props.subscriptions?.length > 0 &&
!props.notificationsGranted;
const navListPadding =
showNotificationGrantBox ||
showNotificationBrowserNotSupportedBox ||
showNotificationContextNotSupportedBox
? "0"
: "";
return ( return (
<> <>
<Toolbar sx={{ display: { xs: 'none', sm: 'block' } }}/> <Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
<List component="nav" sx={{ paddingTop: navListPadding }}> <List component="nav" sx={{ paddingTop: navListPadding }}>
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert/>} {showNotificationBrowserNotSupportedBox && (
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>} <NotificationBrowserNotSupportedAlert />
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>} )}
{!showSubscriptionsList && {showNotificationContextNotSupportedBox && (
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}> <NotificationContextNotSupportedAlert />
<ListItemIcon><ChatBubble/></ListItemIcon> )}
<ListItemText primary={t("nav_button_all_notifications")}/> {showNotificationGrantBox && (
</ListItemButton>} <NotificationGrantAlert
{showSubscriptionsList && onRequestPermissionClick={handleRequestNotificationPermission}
/>
)}
{!showSubscriptionsList && (
<ListItemButton
onClick={() => navigate(routes.app)}
selected={location.pathname === config.app_root}
>
<ListItemIcon>
<ChatBubble />
</ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")} />
</ListItemButton>
)}
{showSubscriptionsList && (
<> <>
<ListSubheader>{t("nav_topics_title")}</ListSubheader> <ListSubheader>{t("nav_topics_title")}</ListSubheader>
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}> <ListItemButton
<ListItemIcon><ChatBubble/></ListItemIcon> onClick={() => navigate(routes.app)}
<ListItemText primary={t("nav_button_all_notifications")}/> selected={location.pathname === config.app_root}
>
<ListItemIcon>
<ChatBubble />
</ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")} />
</ListItemButton> </ListItemButton>
<SubscriptionList <SubscriptionList
subscriptions={props.subscriptions} subscriptions={props.subscriptions}
selectedSubscription={props.selectedSubscription} selectedSubscription={props.selectedSubscription}
/> />
<Divider sx={{my: 1}}/> <Divider sx={{ my: 1 }} />
</>} </>
{session.exists() && )}
<ListItemButton onClick={handleAccountClick} selected={location.pathname === routes.account}> {session.exists() && (
<ListItemIcon><Person/></ListItemIcon> <ListItemButton
<ListItemText primary={t("nav_button_account")}/> onClick={handleAccountClick}
selected={location.pathname === routes.account}
>
<ListItemIcon>
<Person />
</ListItemIcon>
<ListItemText primary={t("nav_button_account")} />
</ListItemButton> </ListItemButton>
} )}
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}> <ListItemButton
<ListItemIcon><SettingsIcon/></ListItemIcon> onClick={() => navigate(routes.settings)}
<ListItemText primary={t("nav_button_settings")}/> selected={location.pathname === routes.settings}
>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_settings")} />
</ListItemButton> </ListItemButton>
<ListItemButton onClick={() => openUrl("/docs")}> <ListItemButton onClick={() => openUrl("/docs")}>
<ListItemIcon><ArticleIcon/></ListItemIcon> <ListItemIcon>
<ListItemText primary={t("nav_button_documentation")}/> <ArticleIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_documentation")} />
</ListItemButton> </ListItemButton>
<ListItemButton onClick={() => props.onPublishMessageClick()}> <ListItemButton onClick={() => props.onPublishMessageClick()}>
<ListItemIcon><Send/></ListItemIcon> <ListItemIcon>
<ListItemText primary={t("nav_button_publish_message")}/> <Send />
</ListItemIcon>
<ListItemText primary={t("nav_button_publish_message")} />
</ListItemButton> </ListItemButton>
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}> <ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
<ListItemIcon><AddIcon/></ListItemIcon> <ListItemIcon>
<ListItemText primary={t("nav_button_subscribe")}/> <AddIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_subscribe")} />
</ListItemButton> </ListItemButton>
{showUpgradeBanner && {showUpgradeBanner && <UpgradeBanner />}
<UpgradeBanner/>
}
</List> </List>
<SubscribeDialog <SubscribeDialog
key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed
@ -182,21 +249,26 @@ const UpgradeBanner = () => {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const handleClick = () => { const handleClick = () => {
setDialogKey(k => k + 1); setDialogKey((k) => k + 1);
setDialogOpen(true); setDialogOpen(true);
}; };
return ( return (
<Box sx={{ <Box
sx={{
position: "fixed", position: "fixed",
width: `${Navigation.width - 1}px`, width: `${Navigation.width - 1}px`,
bottom: 0, bottom: 0,
mt: 'auto', mt: "auto",
background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)", background:
}}> "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
<Divider/> }}
<ListItemButton onClick={handleClick} sx={{pt: 2, pb: 2}}> >
<ListItemIcon><CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large"/></ListItemIcon> <Divider />
<ListItemButton onClick={handleClick} sx={{ pt: 2, pb: 2 }}>
<ListItemIcon>
<CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large" />
</ListItemIcon>
<ListItemText <ListItemText
sx={{ ml: 1 }} sx={{ ml: 1 }}
primary={t("nav_upgrade_banner_label")} primary={t("nav_upgrade_banner_label")}
@ -205,15 +277,16 @@ const UpgradeBanner = () => {
style: { style: {
fontWeight: 500, fontWeight: 500,
fontSize: "1.1rem", fontSize: "1.1rem",
background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)", background:
"-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
WebkitBackgroundClip: "text", WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent" WebkitTextFillColor: "transparent",
} },
}} }}
secondaryTypographyProps={{ secondaryTypographyProps={{
style: { style: {
fontSize: "1rem" fontSize: "1rem",
} },
}} }}
/> />
</ListItemButton> </ListItemButton>
@ -228,21 +301,27 @@ const UpgradeBanner = () => {
const SubscriptionList = (props) => { const SubscriptionList = (props) => {
const sortedSubscriptions = props.subscriptions const sortedSubscriptions = props.subscriptions
.filter(s => !s.internal) .filter((s) => !s.internal)
.sort((a, b) => { .sort((a, b) => {
return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1; return topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)
? -1
: 1;
}); });
return ( return (
<> <>
{sortedSubscriptions.map(subscription => {sortedSubscriptions.map((subscription) => (
<SubscriptionItem <SubscriptionItem
key={subscription.id} key={subscription.id}
subscription={subscription} subscription={subscription}
selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id} selected={
/>)} props.selectedSubscription &&
props.selectedSubscription.id === subscription.id
}
/>
))}
</> </>
); );
} };
const SubscriptionItem = (props) => { const SubscriptionItem = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -250,14 +329,24 @@ const SubscriptionItem = (props) => {
const [menuAnchorEl, setMenuAnchorEl] = useState(null); const [menuAnchorEl, setMenuAnchorEl] = useState(null);
const subscription = props.subscription; const subscription = props.subscription;
const iconBadge = (subscription.new <= 99) ? subscription.new : "99+"; const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
const displayName = topicDisplayName(subscription); const displayName = topicDisplayName(subscription);
const ariaLabel = (subscription.state === ConnectionState.Connecting) const ariaLabel =
subscription.state === ConnectionState.Connecting
? `${displayName} (${t("nav_button_connecting")})` ? `${displayName} (${t("nav_button_connecting")})`
: displayName; : displayName;
const icon = (subscription.state === ConnectionState.Connecting) const icon =
? <CircularProgress size="24px"/> subscription.state === ConnectionState.Connecting ? (
: <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>; <CircularProgress size="24px" />
) : (
<Badge
badgeContent={iconBadge}
invisible={subscription.new === 0}
color="primary"
>
<ChatBubbleOutlineIcon />
</Badge>
);
const handleClick = async () => { const handleClick = async () => {
navigate(routes.forSubscription(subscription)); navigate(routes.forSubscription(subscription));
@ -266,31 +355,59 @@ const SubscriptionItem = (props) => {
return ( return (
<> <>
<ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite"> <ListItemButton
onClick={handleClick}
selected={props.selected}
aria-label={ariaLabel}
aria-live="polite"
>
<ListItemIcon>{icon}</ListItemIcon> <ListItemIcon>{icon}</ListItemIcon>
<ListItemText primary={displayName} primaryTypographyProps={{ style: { overflow: "hidden", textOverflow: "ellipsis" } }}/> <ListItemText
{subscription.reservation?.everyone && primary={displayName}
primaryTypographyProps={{
style: { overflow: "hidden", textOverflow: "ellipsis" },
}}
/>
{subscription.reservation?.everyone && (
<ListItemIcon edge="end" sx={{ minWidth: "26px" }}> <ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
{subscription.reservation?.everyone === Permission.READ_WRITE && {subscription.reservation?.everyone === Permission.READ_WRITE && (
<Tooltip title={t("prefs_reservations_table_everyone_read_write")}><PermissionReadWrite size="small"/></Tooltip> <Tooltip
} title={t("prefs_reservations_table_everyone_read_write")}
{subscription.reservation?.everyone === Permission.READ_ONLY && >
<Tooltip title={t("prefs_reservations_table_everyone_read_only")}><PermissionRead size="small"/></Tooltip> <PermissionReadWrite size="small" />
} </Tooltip>
{subscription.reservation?.everyone === Permission.WRITE_ONLY && )}
<Tooltip title={t("prefs_reservations_table_everyone_write_only")}><PermissionWrite size="small"/></Tooltip> {subscription.reservation?.everyone === Permission.READ_ONLY && (
} <Tooltip title={t("prefs_reservations_table_everyone_read_only")}>
{subscription.reservation?.everyone === Permission.DENY_ALL && <PermissionRead size="small" />
<Tooltip title={t("prefs_reservations_table_everyone_deny_all")}><PermissionDenyAll size="small"/></Tooltip> </Tooltip>
} )}
{subscription.reservation?.everyone === Permission.WRITE_ONLY && (
<Tooltip
title={t("prefs_reservations_table_everyone_write_only")}
>
<PermissionWrite size="small" />
</Tooltip>
)}
{subscription.reservation?.everyone === Permission.DENY_ALL && (
<Tooltip title={t("prefs_reservations_table_everyone_deny_all")}>
<PermissionDenyAll size="small" />
</Tooltip>
)}
</ListItemIcon> </ListItemIcon>
} )}
{subscription.mutedUntil > 0 && {subscription.mutedUntil > 0 && (
<ListItemIcon edge="end" sx={{ minWidth: "26px" }} aria-label={t("nav_button_muted")}> <ListItemIcon
<Tooltip title={t("nav_button_muted")}><NotificationsOffOutlined /></Tooltip> edge="end"
sx={{ minWidth: "26px" }}
aria-label={t("nav_button_muted")}
>
<Tooltip title={t("nav_button_muted")}>
<NotificationsOffOutlined />
</Tooltip>
</ListItemIcon> </ListItemIcon>
} )}
<ListItemIcon edge="end" sx={{minWidth: "26px"}}> <ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
<IconButton <IconButton
size="small" size="small"
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
@ -299,7 +416,7 @@ const SubscriptionItem = (props) => {
setMenuAnchorEl(e.currentTarget); setMenuAnchorEl(e.currentTarget);
}} }}
> >
<MoreVert fontSize="small"/> <MoreVert fontSize="small" />
</IconButton> </IconButton>
</ListItemIcon> </ListItemIcon>
</ListItemButton> </ListItemButton>
@ -318,11 +435,11 @@ const NotificationGrantAlert = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Alert severity="warning" sx={{paddingTop: 2}}> <Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_grant_title")}</AlertTitle> <AlertTitle>{t("alert_grant_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_grant_description")}</Typography> <Typography gutterBottom>{t("alert_grant_description")}</Typography>
<Button <Button
sx={{float: 'right'}} sx={{ float: "right" }}
color="inherit" color="inherit"
size="small" size="small"
onClick={props.onRequestPermissionClick} onClick={props.onRequestPermissionClick}
@ -330,7 +447,7 @@ const NotificationGrantAlert = (props) => {
{t("alert_grant_button")} {t("alert_grant_button")}
</Button> </Button>
</Alert> </Alert>
<Divider/> <Divider />
</> </>
); );
}; };
@ -339,11 +456,13 @@ const NotificationBrowserNotSupportedAlert = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Alert severity="warning" sx={{paddingTop: 2}}> <Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle> <AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_not_supported_description")}</Typography> <Typography gutterBottom>
{t("alert_not_supported_description")}
</Typography>
</Alert> </Alert>
<Divider/> <Divider />
</> </>
); );
}; };
@ -352,18 +471,24 @@ const NotificationContextNotSupportedAlert = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Alert severity="warning" sx={{paddingTop: 2}}> <Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle> <AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom> <Typography gutterBottom>
<Trans <Trans
i18nKey="alert_not_supported_context_description" i18nKey="alert_not_supported_context_description"
components={{ components={{
mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener"/> mdnLink: (
<Link
href="https://developer.mozilla.org/en-US/docs/Web/API/notification"
target="_blank"
rel="noopener"
/>
),
}} }}
/> />
</Typography> </Typography>
</Alert> </Alert>
<Divider/> <Divider />
</> </>
); );
}; };

View file

@ -9,12 +9,12 @@ import {
Modal, Modal,
Snackbar, Snackbar,
Stack, Stack,
Tooltip Tooltip,
} from "@mui/material"; } from "@mui/material";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import * as React from "react"; import * as React from "react";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import { import {
formatBytes, formatBytes,
formatMessage, formatMessage,
@ -24,13 +24,17 @@ import {
openUrl, openUrl,
shortUrl, shortUrl,
topicShortUrl, topicShortUrl,
unmatchedTags unmatchedTags,
} from "../app/utils"; } from "../app/utils";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import CheckIcon from '@mui/icons-material/Check'; import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from "@mui/icons-material/Close";
import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles"; import {
import {useLiveQuery} from "dexie-react-hooks"; LightboxBackdrop,
Paragraph,
VerticallyCenteredContainer,
} from "./styles";
import { useLiveQuery } from "dexie-react-hooks";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
@ -41,50 +45,68 @@ import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg"; import priority5 from "../img/priority-5.svg";
import logoOutline from "../img/ntfy-outline.svg"; import logoOutline from "../img/ntfy-outline.svg";
import AttachmentIcon from "./AttachmentIcon"; import AttachmentIcon from "./AttachmentIcon";
import {Trans, useTranslation} from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import {useOutletContext} from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import {useAutoSubscribe} from "./hooks"; import { useAutoSubscribe } from "./hooks";
export const AllSubscriptions = () => { export const AllSubscriptions = () => {
const { subscriptions } = useOutletContext(); const { subscriptions } = useOutletContext();
if (!subscriptions) { if (!subscriptions) {
return <Loading/>; return <Loading />;
} }
return <AllSubscriptionsList subscriptions={subscriptions}/>; return <AllSubscriptionsList subscriptions={subscriptions} />;
}; };
export const SingleSubscription = () => { export const SingleSubscription = () => {
const { subscriptions, selected } = useOutletContext(); const { subscriptions, selected } = useOutletContext();
useAutoSubscribe(subscriptions, selected); useAutoSubscribe(subscriptions, selected);
if (!selected) { if (!selected) {
return <Loading/>; return <Loading />;
} }
return <SingleSubscriptionList subscription={selected}/>; return <SingleSubscriptionList subscription={selected} />;
}; };
const AllSubscriptionsList = (props) => { const AllSubscriptionsList = (props) => {
const subscriptions = props.subscriptions; const subscriptions = props.subscriptions;
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []); const notifications = useLiveQuery(
() => subscriptionManager.getAllNotifications(),
[]
);
if (notifications === null || notifications === undefined) { if (notifications === null || notifications === undefined) {
return <Loading/>; return <Loading />;
} else if (subscriptions.length === 0) { } else if (subscriptions.length === 0) {
return <NoSubscriptions/>; return <NoSubscriptions />;
} else if (notifications.length === 0) { } else if (notifications.length === 0) {
return <NoNotificationsWithoutSubscription subscriptions={subscriptions}/>; return <NoNotificationsWithoutSubscription subscriptions={subscriptions} />;
} }
return <NotificationList key="all" notifications={notifications} messageBar={false}/>; return (
} <NotificationList
key="all"
notifications={notifications}
messageBar={false}
/>
);
};
const SingleSubscriptionList = (props) => { const SingleSubscriptionList = (props) => {
const subscription = props.subscription; const subscription = props.subscription;
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]); const notifications = useLiveQuery(
() => subscriptionManager.getNotifications(subscription.id),
[subscription]
);
if (notifications === null || notifications === undefined) { if (notifications === null || notifications === undefined) {
return <Loading/>; return <Loading />;
} else if (notifications.length === 0) { } else if (notifications.length === 0) {
return <NoNotifications subscription={subscription}/>; return <NoNotifications subscription={subscription} />;
} }
return <NotificationList id={subscription.id} notifications={notifications} messageBar={true}/>; return (
} <NotificationList
id={subscription.id}
notifications={notifications}
messageBar={true}
/>
);
};
const NotificationList = (props) => { const NotificationList = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -101,13 +123,13 @@ const NotificationList = (props) => {
if (main) { if (main) {
main.scrollTo(0, 0); main.scrollTo(0, 0);
} }
} };
}, [props.id]); }, [props.id]);
return ( return (
<InfiniteScroll <InfiniteScroll
dataLength={count} dataLength={count}
next={() => setMaxCount(prev => prev + pageSize)} next={() => setMaxCount((prev) => prev + pageSize)}
hasMore={count < notifications.length} hasMore={count < notifications.length}
loader={<>Loading ...</>} loader={<>Loading ...</>}
scrollThreshold={0.7} scrollThreshold={0.7}
@ -119,16 +141,17 @@ const NotificationList = (props) => {
aria-label={t("notifications_list")} aria-label={t("notifications_list")}
sx={{ sx={{
marginTop: 3, marginTop: 3,
marginBottom: (props.messageBar) ? "100px" : 3 // Hack to avoid hiding notifications behind the message bar marginBottom: props.messageBar ? "100px" : 3, // Hack to avoid hiding notifications behind the message bar
}} }}
> >
<Stack spacing={3}> <Stack spacing={3}>
{notifications.slice(0, count).map(notification => {notifications.slice(0, count).map((notification) => (
<NotificationItem <NotificationItem
key={notification.id} key={notification.id}
notification={notification} notification={notification}
onShowSnack={() => setSnackOpen(true)} onShowSnack={() => setSnackOpen(true)}
/>)} />
))}
<Snackbar <Snackbar
open={snackOpen} open={snackOpen}
autoHideDuration={3000} autoHideDuration={3000}
@ -139,7 +162,7 @@ const NotificationList = (props) => {
</Container> </Container>
</InfiniteScroll> </InfiniteScroll>
); );
} };
const NotificationItem = (props) => { const NotificationItem = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -147,81 +170,138 @@ const NotificationItem = (props) => {
const attachment = notification.attachment; const attachment = notification.attachment;
const date = formatShortDateTime(notification.time); const date = formatShortDateTime(notification.time);
const otherTags = unmatchedTags(notification.tags); const otherTags = unmatchedTags(notification.tags);
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null; const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
const handleDelete = async () => { const handleDelete = async () => {
console.log(`[Notifications] Deleting notification ${notification.id}`); console.log(`[Notifications] Deleting notification ${notification.id}`);
await subscriptionManager.deleteNotification(notification.id) await subscriptionManager.deleteNotification(notification.id);
} };
const handleMarkRead = async () => { const handleMarkRead = async () => {
console.log(`[Notifications] Marking notification ${notification.id} as read`); console.log(
await subscriptionManager.markNotificationRead(notification.id) `[Notifications] Marking notification ${notification.id} as read`
} );
await subscriptionManager.markNotificationRead(notification.id);
};
const handleCopy = (s) => { const handleCopy = (s) => {
navigator.clipboard.writeText(s); navigator.clipboard.writeText(s);
props.onShowSnack(); props.onShowSnack();
}; };
const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000; const expired =
attachment && attachment.expires && attachment.expires < Date.now() / 1000;
const hasAttachmentActions = attachment && !expired; const hasAttachmentActions = attachment && !expired;
const hasClickAction = notification.click; const hasClickAction = notification.click;
const hasUserActions = notification.actions && notification.actions.length > 0; const hasUserActions =
notification.actions && notification.actions.length > 0;
const showActions = hasAttachmentActions || hasClickAction || hasUserActions; const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
return ( return (
<Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}> <Card
sx={{ minWidth: 275, padding: 1 }}
role="listitem"
aria-label={t("notifications_list_item")}
>
<CardContent> <CardContent>
<Tooltip title={t("notifications_delete")} enterDelay={500}> <Tooltip title={t("notifications_delete")} enterDelay={500}>
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}> <IconButton
onClick={handleDelete}
sx={{ float: "right", marginRight: -1, marginTop: -1 }}
aria-label={t("notifications_delete")}
>
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{notification.new === 1 && {notification.new === 1 && (
<Tooltip title={t("notifications_mark_read")} enterDelay={500}> <Tooltip title={t("notifications_mark_read")} enterDelay={500}>
<IconButton onClick={handleMarkRead} sx={{ float: 'right', marginRight: -0.5, marginTop: -1 }} aria-label={t("notifications_mark_read")}> <IconButton
onClick={handleMarkRead}
sx={{ float: "right", marginRight: -0.5, marginTop: -1 }}
aria-label={t("notifications_mark_read")}
>
<CheckIcon /> <CheckIcon />
</IconButton> </IconButton>
</Tooltip>} </Tooltip>
)}
<Typography sx={{ fontSize: 14 }} color="text.secondary"> <Typography sx={{ fontSize: 14 }} color="text.secondary">
{date} {date}
{[1,2,4,5].includes(notification.priority) && {[1, 2, 4, 5].includes(notification.priority) && (
<img <img
src={priorityFiles[notification.priority]} src={priorityFiles[notification.priority]}
alt={t("notifications_priority_x", { priority: notification.priority})} alt={t("notifications_priority_x", {
style={{ verticalAlign: 'bottom' }} priority: notification.priority,
/>} })}
{notification.new === 1 && style={{ verticalAlign: "bottom" }}
<svg style={{ width: '8px', height: '8px', marginLeft: '4px' }} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-label={t("notifications_new_indicator")}> />
<circle cx="50" cy="50" r="50" fill="#338574"/> )}
</svg>} {notification.new === 1 && (
<svg
style={{ width: "8px", height: "8px", marginLeft: "4px" }}
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
aria-label={t("notifications_new_indicator")}
>
<circle cx="50" cy="50" r="50" fill="#338574" />
</svg>
)}
</Typography> </Typography>
{notification.title && <Typography variant="h5" component="div" role="rowheader">{formatTitle(notification)}</Typography>} {notification.title && (
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}> <Typography variant="h5" component="div" role="rowheader">
{autolink(maybeAppendActionErrors(formatMessage(notification), notification))} {formatTitle(notification)}
</Typography> </Typography>
{attachment && <Attachment attachment={attachment}/>} )}
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">{t("notifications_tags")}: {tags}</Typography>} <Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
{autolink(
maybeAppendActionErrors(formatMessage(notification), notification)
)}
</Typography>
{attachment && <Attachment attachment={attachment} />}
{tags && (
<Typography sx={{ fontSize: 14 }} color="text.secondary">
{t("notifications_tags")}: {tags}
</Typography>
)}
</CardContent> </CardContent>
{showActions && {showActions && (
<CardActions sx={{paddingTop: 0}}> <CardActions sx={{ paddingTop: 0 }}>
{hasAttachmentActions && <> {hasAttachmentActions && (
<>
<Tooltip title={t("notifications_attachment_copy_url_title")}> <Tooltip title={t("notifications_attachment_copy_url_title")}>
<Button onClick={() => handleCopy(attachment.url)}>{t("notifications_attachment_copy_url_button")}</Button> <Button onClick={() => handleCopy(attachment.url)}>
{t("notifications_attachment_copy_url_button")}
</Button>
</Tooltip> </Tooltip>
<Tooltip title={t("notifications_attachment_open_title", { url: attachment.url })}> <Tooltip
<Button onClick={() => openUrl(attachment.url)}>{t("notifications_attachment_open_button")}</Button> title={t("notifications_attachment_open_title", {
url: attachment.url,
})}
>
<Button onClick={() => openUrl(attachment.url)}>
{t("notifications_attachment_open_button")}
</Button>
</Tooltip> </Tooltip>
</>} </>
{hasClickAction && <> )}
{hasClickAction && (
<>
<Tooltip title={t("notifications_click_copy_url_title")}> <Tooltip title={t("notifications_click_copy_url_title")}>
<Button onClick={() => handleCopy(notification.click)}>{t("notifications_click_copy_url_button")}</Button> <Button onClick={() => handleCopy(notification.click)}>
{t("notifications_click_copy_url_button")}
</Button>
</Tooltip> </Tooltip>
<Tooltip title={t("notifications_actions_open_url_title", { url: notification.click })}> <Tooltip
<Button onClick={() => openUrl(notification.click)}>{t("notifications_click_open_button")}</Button> title={t("notifications_actions_open_url_title", {
url: notification.click,
})}
>
<Button onClick={() => openUrl(notification.click)}>
{t("notifications_click_open_button")}
</Button>
</Tooltip> </Tooltip>
</>} </>
{hasUserActions && <UserActions notification={notification}/>} )}
</CardActions>} {hasUserActions && <UserActions notification={notification} />}
</CardActions>
)}
</Card> </Card>
); );
} };
/** /**
* Replace links with <Link/> components; this is a combination of the genius function * Replace links with <Link/> components; this is a combination of the genius function
@ -231,9 +311,21 @@ const NotificationItem = (props) => {
* [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9 * [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9
*/ */
const autolink = (s) => { const autolink = (s) => {
const parts = s.split(/(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi); 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) { for (let i = 1; i < parts.length; i += 2) {
parts[i] = <Link key={i} href={parts[i]} underline="hover" target="_blank" rel="noreferrer,noopener">{shortUrl(parts[i])}</Link>; parts[i] = (
<Link
key={i}
href={parts[i]}
underline="hover"
target="_blank"
rel="noreferrer,noopener"
>
{shortUrl(parts[i])}
</Link>
);
} }
return <>{parts}</>; return <>{parts}</>;
}; };
@ -242,19 +334,20 @@ const priorityFiles = {
1: priority1, 1: priority1,
2: priority2, 2: priority2,
4: priority4, 4: priority4,
5: priority5 5: priority5,
}; };
const Attachment = (props) => { const Attachment = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const attachment = props.attachment; const attachment = props.attachment;
const expired = attachment.expires && attachment.expires < Date.now()/1000; const expired = attachment.expires && attachment.expires < Date.now() / 1000;
const expires = attachment.expires && attachment.expires > Date.now()/1000; const expires = attachment.expires && attachment.expires > Date.now() / 1000;
const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/"); const displayableImage =
!expired && attachment.type && attachment.type.startsWith("image/");
// Unexpired image // Unexpired image
if (displayableImage) { if (displayableImage) {
return <Image attachment={attachment}/>; return <Image attachment={attachment} />;
} }
// Anything else: Show box // Anything else: Show box
@ -263,25 +356,40 @@ const Attachment = (props) => {
infos.push(formatBytes(attachment.size)); infos.push(formatBytes(attachment.size));
} }
if (expires) { if (expires) {
infos.push(t("notifications_attachment_link_expires", { date: formatShortDateTime(attachment.expires) })); infos.push(
t("notifications_attachment_link_expires", {
date: formatShortDateTime(attachment.expires),
})
);
} }
if (expired) { if (expired) {
infos.push(t("notifications_attachment_link_expired")); infos.push(t("notifications_attachment_link_expired"));
} }
const maybeInfoText = (infos.length > 0) ? <><br/>{infos.join(", ")}</> : null; const maybeInfoText =
infos.length > 0 ? (
<>
<br />
{infos.join(", ")}
</>
) : null;
// If expired, just show infos without click target // If expired, just show infos without click target
if (expired) { if (expired) {
return ( return (
<Box sx={{ <Box
display: 'flex', sx={{
alignItems: 'center', display: "flex",
alignItems: "center",
marginTop: 2, marginTop: 2,
padding: 1, padding: 1,
borderRadius: '4px', borderRadius: "4px",
}}> }}
<AttachmentIcon type={attachment.type}/> >
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}> <AttachmentIcon type={attachment.type} />
<Typography
variant="body2"
sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}
>
<b>{attachment.name}</b> <b>{attachment.name}</b>
{maybeInfoText} {maybeInfoText}
</Typography> </Typography>
@ -291,26 +399,31 @@ const Attachment = (props) => {
// Not expired // Not expired
return ( return (
<ButtonBase sx={{ <ButtonBase
sx={{
marginTop: 2, marginTop: 2,
}}> }}
>
<Link <Link
href={attachment.url} href={attachment.url}
target="_blank" target="_blank"
rel="noopener" rel="noopener"
underline="none" underline="none"
sx={{ sx={{
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
padding: 1, padding: 1,
borderRadius: '4px', borderRadius: "4px",
'&:hover': { "&:hover": {
backgroundColor: 'rgba(0, 0, 0, 0.05)' backgroundColor: "rgba(0, 0, 0, 0.05)",
} },
}} }}
> >
<AttachmentIcon type={attachment.type}/> <AttachmentIcon type={attachment.type} />
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}> <Typography
variant="body2"
sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}
>
<b>{attachment.name}</b> <b>{attachment.name}</b>
{maybeInfoText} {maybeInfoText}
</Typography> </Typography>
@ -332,12 +445,12 @@ const Image = (props) => {
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
sx={{ sx={{
marginTop: 2, marginTop: 2,
borderRadius: '4px', borderRadius: "4px",
boxShadow: 2, boxShadow: 2,
width: 1, width: 1,
maxHeight: '400px', maxHeight: "400px",
objectFit: 'cover', objectFit: "cover",
cursor: 'pointer' cursor: "pointer",
}} }}
/> />
<Modal <Modal
@ -354,10 +467,10 @@ const Image = (props) => {
sx={{ sx={{
maxWidth: 1, maxWidth: 1,
maxHeight: 1, maxHeight: 1,
position: 'absolute', position: "absolute",
top: '50%', top: "50%",
left: '50%', left: "50%",
transform: 'translate(-50%, -50%)', transform: "translate(-50%, -50%)",
padding: 4, padding: 4,
}} }}
/> />
@ -365,12 +478,19 @@ const Image = (props) => {
</Modal> </Modal>
</> </>
); );
} };
const UserActions = (props) => { const UserActions = (props) => {
return ( return (
<>{props.notification.actions.map(action => <>
<UserAction key={action.id} notification={props.notification} action={action}/>)}</> {props.notification.actions.map((action) => (
<UserAction
key={action.id}
notification={props.notification}
action={action}
/>
))}
</>
); );
}; };
@ -381,27 +501,51 @@ const UserAction = (props) => {
if (action.action === "broadcast") { if (action.action === "broadcast") {
return ( return (
<Tooltip title={t("notifications_actions_not_supported")}> <Tooltip title={t("notifications_actions_not_supported")}>
<span><Button disabled aria-label={t("notifications_actions_not_supported")}>{action.label}</Button></span> <span>
<Button
disabled
aria-label={t("notifications_actions_not_supported")}
>
{action.label}
</Button>
</span>
</Tooltip> </Tooltip>
); );
} else if (action.action === "view") { } else if (action.action === "view") {
return ( return (
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}> <Tooltip
title={t("notifications_actions_open_url_title", { url: action.url })}
>
<Button <Button
onClick={() => openUrl(action.url)} onClick={() => openUrl(action.url)}
aria-label={t("notifications_actions_open_url_title", { url: action.url })} aria-label={t("notifications_actions_open_url_title", {
>{action.label}</Button> url: action.url,
})}
>
{action.label}
</Button>
</Tooltip> </Tooltip>
); );
} else if (action.action === "http") { } else if (action.action === "http") {
const method = action.method ?? "POST"; const method = action.method ?? "POST";
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? ""); const label =
action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
return ( return (
<Tooltip title={t("notifications_actions_http_request_title", { method: method, url: action.url })}> <Tooltip
title={t("notifications_actions_http_request_title", {
method: method,
url: action.url,
})}
>
<Button <Button
onClick={() => performHttpAction(notification, action)} onClick={() => performHttpAction(notification, action)}
aria-label={t("notifications_actions_http_request_title", { method: method, url: action.url })} aria-label={t("notifications_actions_http_request_title", {
>{label}</Button> method: method,
url: action.url,
})}
>
{label}
</Button>
</Tooltip> </Tooltip>
); );
} }
@ -417,30 +561,40 @@ const performHttpAction = async (notification, action) => {
headers: action.headers ?? {}, headers: action.headers ?? {},
// This must not null-coalesce to a non nullish value. Otherwise, the fetch API // This must not null-coalesce to a non nullish value. Otherwise, the fetch API
// will reject it for "having a body" // will reject it for "having a body"
body: action.body body: action.body,
}); });
console.log(`[Notifications] HTTP user action response`, response); console.log(`[Notifications] HTTP user action response`, response);
const success = response.status >= 200 && response.status <= 299; const success = response.status >= 200 && response.status <= 299;
if (success) { if (success) {
updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null); updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null);
} else { } else {
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`); updateActionStatus(
notification,
action,
ACTION_PROGRESS_FAILED,
`${action.label}: Unexpected response HTTP ${response.status}`
);
} }
} catch (e) { } catch (e) {
console.log(`[Notifications] HTTP action failed`, e); console.log(`[Notifications] HTTP action failed`, e);
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`); updateActionStatus(
notification,
action,
ACTION_PROGRESS_FAILED,
`${action.label}: ${e} Check developer console for details.`
);
} }
}; };
const updateActionStatus = (notification, action, progress, error) => { const updateActionStatus = (notification, action, progress, error) => {
notification.actions = notification.actions.map(a => { notification.actions = notification.actions.map((a) => {
if (a.id !== action.id) { if (a.id !== action.id) {
return a; return a;
} }
return { ...a, progress: progress, error: error }; return { ...a, progress: progress, error: error };
}); });
subscriptionManager.updateNotification(notification); subscriptionManager.updateNotification(notification);
} };
const ACTION_PROGRESS_ONGOING = 1; const ACTION_PROGRESS_ONGOING = 1;
const ACTION_PROGRESS_SUCCESS = 2; const ACTION_PROGRESS_SUCCESS = 2;
@ -449,29 +603,34 @@ const ACTION_PROGRESS_FAILED = 3;
const ACTION_LABEL_SUFFIX = { const ACTION_LABEL_SUFFIX = {
[ACTION_PROGRESS_ONGOING]: " …", [ACTION_PROGRESS_ONGOING]: " …",
[ACTION_PROGRESS_SUCCESS]: " ✔", [ACTION_PROGRESS_SUCCESS]: " ✔",
[ACTION_PROGRESS_FAILED]: " ❌" [ACTION_PROGRESS_FAILED]: " ❌",
}; };
const NoNotifications = (props) => { const NoNotifications = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic); const shortUrl = topicShortUrl(
props.subscription.baseUrl,
props.subscription.topic
);
return ( return (
<VerticallyCenteredContainer maxWidth="xs"> <VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br /> <img
src={logoOutline}
height="64"
width="64"
alt={t("action_bar_logo_alt")}
/>
<br />
{t("notifications_none_for_topic_title")} {t("notifications_none_for_topic_title")}
</Typography> </Typography>
<Paragraph>{t("notifications_none_for_topic_description")}</Paragraph>
<Paragraph> <Paragraph>
{t("notifications_none_for_topic_description")} {t("notifications_example")}:<br />
<tt>$ curl -d "Hi" {shortUrl}</tt>
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
{t("notifications_example")}:<br/> <ForMoreDetails />
<tt>
$ curl -d "Hi" {shortUrl}
</tt>
</Paragraph>
<Paragraph>
<ForMoreDetails/>
</Paragraph> </Paragraph>
</VerticallyCenteredContainer> </VerticallyCenteredContainer>
); );
@ -484,20 +643,22 @@ const NoNotificationsWithoutSubscription = (props) => {
return ( return (
<VerticallyCenteredContainer maxWidth="xs"> <VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br /> <img
src={logoOutline}
height="64"
width="64"
alt={t("action_bar_logo_alt")}
/>
<br />
{t("notifications_none_for_any_title")} {t("notifications_none_for_any_title")}
</Typography> </Typography>
<Paragraph>{t("notifications_none_for_any_description")}</Paragraph>
<Paragraph> <Paragraph>
{t("notifications_none_for_any_description")} {t("notifications_example")}:<br />
<tt>$ curl -d "Hi" {shortUrl}</tt>
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
{t("notifications_example")}:<br/> <ForMoreDetails />
<tt>
$ curl -d "Hi" {shortUrl}
</tt>
</Paragraph>
<Paragraph>
<ForMoreDetails/>
</Paragraph> </Paragraph>
</VerticallyCenteredContainer> </VerticallyCenteredContainer>
); );
@ -508,16 +669,22 @@ const NoSubscriptions = () => {
return ( return (
<VerticallyCenteredContainer maxWidth="xs"> <VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br /> <img
src={logoOutline}
height="64"
width="64"
alt={t("action_bar_logo_alt")}
/>
<br />
{t("notifications_no_subscriptions_title")} {t("notifications_no_subscriptions_title")}
</Typography> </Typography>
<Paragraph> <Paragraph>
{t("notifications_no_subscriptions_description", { {t("notifications_no_subscriptions_description", {
linktext: t("nav_button_subscribe") linktext: t("nav_button_subscribe"),
})} })}
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
<ForMoreDetails/> <ForMoreDetails />
</Paragraph> </Paragraph>
</VerticallyCenteredContainer> </VerticallyCenteredContainer>
); );
@ -528,8 +695,12 @@ const ForMoreDetails = () => {
<Trans <Trans
i18nKey="notifications_more_details" i18nKey="notifications_more_details"
components={{ components={{
websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener"/>, websiteLink: (
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/> <Link href="https://ntfy.sh" target="_blank" rel="noopener" />
),
docsLink: (
<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />
),
}} }}
/> />
); );
@ -539,8 +710,14 @@ const Loading = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<VerticallyCenteredContainer> <VerticallyCenteredContainer>
<Typography variant="h5" color="text.secondary" align="center" sx={{ paddingBottom: 1 }}> <Typography
<CircularProgress disableShrink sx={{marginBottom: 1}}/><br /> variant="h5"
color="text.secondary"
align="center"
sx={{ paddingBottom: 1 }}
>
<CircularProgress disableShrink sx={{ marginBottom: 1 }} />
<br />
{t("notifications_loading")} {t("notifications_loading")}
</Typography> </Typography>
</VerticallyCenteredContainer> </VerticallyCenteredContainer>

View file

@ -1,9 +1,9 @@
import {Fade, Menu} from "@mui/material"; import { Fade, Menu } from "@mui/material";
import * as React from "react"; import * as React from "react";
const PopupMenu = (props) => { const PopupMenu = (props) => {
const horizontal = props.horizontal ?? "left"; const horizontal = props.horizontal ?? "left";
const arrow = (horizontal === "right") ? { right: 19 } : { left: 19 }; const arrow = horizontal === "right" ? { right: 19 } : { left: 19 };
return ( return (
<Menu <Menu
anchorEl={props.anchorEl} anchorEl={props.anchorEl}
@ -14,31 +14,31 @@ const PopupMenu = (props) => {
PaperProps={{ PaperProps={{
elevation: 0, elevation: 0,
sx: { sx: {
overflow: 'visible', overflow: "visible",
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))', filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))",
mt: 1.5, mt: 1.5,
'& .MuiAvatar-root': { "& .MuiAvatar-root": {
width: 32, width: 32,
height: 32, height: 32,
ml: -0.5, ml: -0.5,
mr: 1, mr: 1,
}, },
'&:before': { "&:before": {
content: '""', content: '""',
display: 'block', display: "block",
position: 'absolute', position: "absolute",
top: 0, top: 0,
width: 10, width: 10,
height: 10, height: 10,
bgcolor: 'background.paper', bgcolor: "background.paper",
transform: 'translateY(-50%) rotate(45deg)', transform: "translateY(-50%) rotate(45deg)",
zIndex: 0, zIndex: 0,
...arrow ...arrow,
}, },
}, },
}} }}
transformOrigin={{ horizontal: horizontal, vertical: 'top' }} transformOrigin={{ horizontal: horizontal, vertical: "top" }}
anchorOrigin={{ horizontal: horizontal, vertical: 'bottom' }} anchorOrigin={{ horizontal: horizontal, vertical: "bottom" }}
> >
{props.children} {props.children}
</Menu> </Menu>

View file

@ -1,15 +1,11 @@
import * as React from "react"; import * as React from "react";
export const PrefGroup = (props) => { export const PrefGroup = (props) => {
return ( return <div role="table">{props.children}</div>;
<div role="table">
{props.children}
</div>
)
}; };
export const Pref = (props) => { export const Pref = (props) => {
const justifyContent = (props.alignTop) ? "normal" : "center"; const justifyContent = props.alignTop ? "normal" : "center";
return ( return (
<div <div
role="row" role="row"
@ -25,23 +21,30 @@ export const Pref = (props) => {
id={props.labelId ?? ""} id={props.labelId ?? ""}
aria-label={props.title} aria-label={props.title}
style={{ style={{
flex: '1 0 40%', flex: "1 0 40%",
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
justifyContent: justifyContent, justifyContent: justifyContent,
paddingRight: '30px' paddingRight: "30px",
}} }}
> >
<div><b>{props.title}</b>{props.subtitle && <em> ({props.subtitle})</em>}</div> <div>
{props.description && <div><em>{props.description}</em></div>} <b>{props.title}</b>
{props.subtitle && <em> ({props.subtitle})</em>}
</div>
{props.description && (
<div>
<em>{props.description}</em>
</div>
)}
</div> </div>
<div <div
role="cell" role="cell"
style={{ style={{
flex: '1 0 calc(60% - 50px)', flex: "1 0 calc(60% - 50px)",
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
justifyContent: justifyContent justifyContent: justifyContent,
}} }}
> >
{props.children} {props.children}

View file

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from "react";
import {useContext, useEffect, useState} from 'react'; import { useContext, useEffect, useState } from "react";
import { import {
Alert, Alert,
CardActions, CardActions,
@ -14,50 +14,59 @@ import {
TableHead, TableHead,
TableRow, TableRow,
Tooltip, Tooltip,
useMediaQuery useMediaQuery,
} from "@mui/material"; } from "@mui/material";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import prefs from "../app/Prefs"; import prefs from "../app/Prefs";
import {Paragraph} from "./styles"; import { Paragraph } from "./styles";
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from "@mui/icons-material/Edit";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import Container from "@mui/material/Container"; import Container from "@mui/material/Container";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import {useLiveQuery} from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import theme from "./theme"; import theme from "./theme";
import Dialog from "@mui/material/Dialog"; import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle"; import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent"; import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions"; import DialogActions from "@mui/material/DialogActions";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import {playSound, shuffle, sounds, validUrl} from "../app/utils"; import { playSound, shuffle, sounds, validUrl } from "../app/utils";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi, {Permission, Role} from "../app/AccountApi"; import accountApi, { Permission, Role } from "../app/AccountApi";
import {Pref, PrefGroup} from "./Pref"; import { Pref, PrefGroup } from "./Pref";
import {Info} from "@mui/icons-material"; import { Info } from "@mui/icons-material";
import {AccountContext} from "./App"; import { AccountContext } from "./App";
import {useOutletContext} from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; import {
import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs"; PermissionDenyAll,
import {UnauthorizedError} from "../app/errors"; PermissionRead,
PermissionReadWrite,
PermissionWrite,
} from "./ReserveIcons";
import {
ReserveAddDialog,
ReserveDeleteDialog,
ReserveEditDialog,
} from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import {subscribeTopic} from "./SubscribeDialog"; import { subscribeTopic } from "./SubscribeDialog";
const Preferences = () => { const Preferences = () => {
return ( return (
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}> <Container maxWidth="md" sx={{ marginTop: 3, marginBottom: 3 }}>
<Stack spacing={3}> <Stack spacing={3}>
<Notifications/> <Notifications />
<Reservations/> <Reservations />
<Users/> <Users />
<Appearance/> <Appearance />
</Stack> </Stack>
</Container> </Container>
); );
@ -66,14 +75,14 @@ const Preferences = () => {
const Notifications = () => { const Notifications = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Card sx={{p: 3}} aria-label={t("prefs_notifications_title")}> <Card sx={{ p: 3 }} aria-label={t("prefs_notifications_title")}>
<Typography variant="h5" sx={{marginBottom: 2}}> <Typography variant="h5" sx={{ marginBottom: 2 }}>
{t("prefs_notifications_title")} {t("prefs_notifications_title")}
</Typography> </Typography>
<PrefGroup> <PrefGroup>
<Sound/> <Sound />
<MinPriority/> <MinPriority />
<DeleteAfter/> <DeleteAfter />
</PrefGroup> </PrefGroup>
</Card> </Card>
); );
@ -87,10 +96,10 @@ const Sound = () => {
await prefs.setSound(ev.target.value); await prefs.setSound(ev.target.value);
await maybeUpdateAccountSettings({ await maybeUpdateAccountSettings({
notification: { notification: {
sound: ev.target.value sound: ev.target.value,
} },
}); });
} };
if (!sound) { if (!sound) {
return null; // While loading return null; // While loading
} }
@ -98,23 +107,43 @@ const Sound = () => {
if (sound === "none") { if (sound === "none") {
description = t("prefs_notifications_sound_description_none"); description = t("prefs_notifications_sound_description_none");
} else { } else {
description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label }); description = t("prefs_notifications_sound_description_some", {
sound: sounds[sound].label,
});
} }
return ( return (
<Pref labelId={labelId} title={t("prefs_notifications_sound_title")} description={description}> <Pref
<div style={{ display: 'flex', width: '100%' }}> labelId={labelId}
title={t("prefs_notifications_sound_title")}
description={description}
>
<div style={{ display: "flex", width: "100%" }}>
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}> <FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
<Select value={sound} onChange={handleChange} aria-labelledby={labelId}> <Select
<MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem> value={sound}
{Object.entries(sounds).map(s => <MenuItem key={s[0]} value={s[0]}>{s[1].label}</MenuItem>)} onChange={handleChange}
aria-labelledby={labelId}
>
<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}
</MenuItem>
))}
</Select> </Select>
</FormControl> </FormControl>
<IconButton onClick={() => playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}> <IconButton
onClick={() => playSound(sound)}
disabled={sound === "none"}
aria-label={t("prefs_notifications_sound_play")}
>
<PlayArrowIcon /> <PlayArrowIcon />
</IconButton> </IconButton>
</div> </div>
</Pref> </Pref>
) );
}; };
const MinPriority = () => { const MinPriority = () => {
@ -125,10 +154,10 @@ const MinPriority = () => {
await prefs.setMinPriority(ev.target.value); await prefs.setMinPriority(ev.target.value);
await maybeUpdateAccountSettings({ await maybeUpdateAccountSettings({
notification: { notification: {
min_priority: ev.target.value min_priority: ev.target.value,
} },
}); });
} };
if (!minPriority) { if (!minPriority) {
return null; // While loading return null; // While loading
} }
@ -137,32 +166,53 @@ const MinPriority = () => {
2: t("priority_low"), 2: t("priority_low"),
3: t("priority_default"), 3: t("priority_default"),
4: t("priority_high"), 4: t("priority_high"),
5: t("priority_max") 5: t("priority_max"),
} };
let description; let description;
if (minPriority === 1) { if (minPriority === 1) {
description = t("prefs_notifications_min_priority_description_any"); description = t("prefs_notifications_min_priority_description_any");
} else if (minPriority === 5) { } else if (minPriority === 5) {
description = t("prefs_notifications_min_priority_description_max"); description = t("prefs_notifications_min_priority_description_max");
} else { } else {
description = t("prefs_notifications_min_priority_description_x_or_higher", { description = t(
"prefs_notifications_min_priority_description_x_or_higher",
{
number: minPriority, number: minPriority,
name: priorities[minPriority] name: priorities[minPriority],
}); }
);
} }
return ( return (
<Pref labelId={labelId} title={t("prefs_notifications_min_priority_title")} description={description}> <Pref
labelId={labelId}
title={t("prefs_notifications_min_priority_title")}
description={description}
>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}> <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={minPriority} onChange={handleChange} aria-labelledby={labelId}> <Select
<MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem> value={minPriority}
<MenuItem value={2}>{t("prefs_notifications_min_priority_low_and_higher")}</MenuItem> onChange={handleChange}
<MenuItem value={3}>{t("prefs_notifications_min_priority_default_and_higher")}</MenuItem> aria-labelledby={labelId}
<MenuItem value={4}>{t("prefs_notifications_min_priority_high_and_higher")}</MenuItem> >
<MenuItem value={5}>{t("prefs_notifications_min_priority_max_only")}</MenuItem> <MenuItem value={1}>
{t("prefs_notifications_min_priority_any")}
</MenuItem>
<MenuItem value={2}>
{t("prefs_notifications_min_priority_low_and_higher")}
</MenuItem>
<MenuItem value={3}>
{t("prefs_notifications_min_priority_default_and_higher")}
</MenuItem>
<MenuItem value={4}>
{t("prefs_notifications_min_priority_high_and_higher")}
</MenuItem>
<MenuItem value={5}>
{t("prefs_notifications_min_priority_max_only")}
</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</Pref> </Pref>
) );
}; };
const DeleteAfter = () => { const DeleteAfter = () => {
@ -173,35 +223,59 @@ const DeleteAfter = () => {
await prefs.setDeleteAfter(ev.target.value); await prefs.setDeleteAfter(ev.target.value);
await maybeUpdateAccountSettings({ await maybeUpdateAccountSettings({
notification: { notification: {
delete_after: ev.target.value delete_after: ev.target.value,
} },
}); });
} };
if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0" if (deleteAfter === null || deleteAfter === undefined) {
// !deleteAfter will not work with "0"
return null; // While loading return null; // While loading
} }
const description = (() => { const description = (() => {
switch (deleteAfter) { switch (deleteAfter) {
case 0: return t("prefs_notifications_delete_after_never_description"); case 0:
case 10800: return t("prefs_notifications_delete_after_three_hours_description"); return t("prefs_notifications_delete_after_never_description");
case 86400: return t("prefs_notifications_delete_after_one_day_description"); case 10800:
case 604800: return t("prefs_notifications_delete_after_one_week_description"); return t("prefs_notifications_delete_after_three_hours_description");
case 2592000: return t("prefs_notifications_delete_after_one_month_description"); case 86400:
return t("prefs_notifications_delete_after_one_day_description");
case 604800:
return t("prefs_notifications_delete_after_one_week_description");
case 2592000:
return t("prefs_notifications_delete_after_one_month_description");
} }
})(); })();
return ( return (
<Pref labelId={labelId} title={t("prefs_notifications_delete_after_title")} description={description}> <Pref
labelId={labelId}
title={t("prefs_notifications_delete_after_title")}
description={description}
>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}> <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={deleteAfter} onChange={handleChange} aria-labelledby={labelId}> <Select
<MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem> value={deleteAfter}
<MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem> onChange={handleChange}
<MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem> aria-labelledby={labelId}
<MenuItem value={604800}>{t("prefs_notifications_delete_after_one_week")}</MenuItem> >
<MenuItem value={2592000}>{t("prefs_notifications_delete_after_one_month")}</MenuItem> <MenuItem value={0}>
{t("prefs_notifications_delete_after_never")}
</MenuItem>
<MenuItem value={10800}>
{t("prefs_notifications_delete_after_three_hours")}
</MenuItem>
<MenuItem value={86400}>
{t("prefs_notifications_delete_after_one_day")}
</MenuItem>
<MenuItem value={604800}>
{t("prefs_notifications_delete_after_one_week")}
</MenuItem>
<MenuItem value={2592000}>
{t("prefs_notifications_delete_after_one_month")}
</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</Pref> </Pref>
) );
}; };
const Users = () => { const Users = () => {
@ -210,7 +284,7 @@ const Users = () => {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const users = useLiveQuery(() => userManager.all()); const users = useLiveQuery(() => userManager.all());
const handleAddClick = () => { const handleAddClick = () => {
setDialogKey(prev => prev+1); setDialogKey((prev) => prev + 1);
setDialogOpen(true); setDialogOpen(true);
}; };
const handleDialogCancel = () => { const handleDialogCancel = () => {
@ -220,7 +294,9 @@ const Users = () => {
setDialogOpen(false); setDialogOpen(false);
try { try {
await userManager.save(user); await userManager.save(user);
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`); console.debug(
`[Preferences] User ${user.username} for ${user.baseUrl} added`
);
} catch (e) { } catch (e) {
console.log(`[Preferences] Error adding user.`, e); console.log(`[Preferences] Error adding user.`, e);
} }
@ -228,14 +304,16 @@ const Users = () => {
return ( return (
<Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}> <Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
<CardContent sx={{ paddingBottom: 1 }}> <CardContent sx={{ paddingBottom: 1 }}>
<Typography variant="h5" sx={{marginBottom: 2}}> <Typography variant="h5" sx={{ marginBottom: 2 }}>
{t("prefs_users_title")} {t("prefs_users_title")}
</Typography> </Typography>
<Paragraph> <Paragraph>
{t("prefs_users_description")} {t("prefs_users_description")}
{session.exists() && <>{" " + t("prefs_users_description_no_sync")}</>} {session.exists() && (
<>{" " + t("prefs_users_description_no_sync")}</>
)}
</Paragraph> </Paragraph>
{users?.length > 0 && <UserTable users={users}/>} {users?.length > 0 && <UserTable users={users} />}
</CardContent> </CardContent>
<CardActions> <CardActions>
<Button onClick={handleAddClick}>{t("prefs_users_add_button")}</Button> <Button onClick={handleAddClick}>{t("prefs_users_add_button")}</Button>
@ -259,7 +337,7 @@ const UserTable = (props) => {
const [dialogUser, setDialogUser] = useState(null); const [dialogUser, setDialogUser] = useState(null);
const handleEditClick = (user) => { const handleEditClick = (user) => {
setDialogKey(prev => prev+1); setDialogKey((prev) => prev + 1);
setDialogUser(user); setDialogUser(user);
setDialogOpen(true); setDialogOpen(true);
}; };
@ -272,7 +350,9 @@ const UserTable = (props) => {
setDialogOpen(false); setDialogOpen(false);
try { try {
await userManager.save(user); await userManager.save(user);
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`); console.debug(
`[Preferences] User ${user.username} for ${user.baseUrl} updated`
);
} catch (e) { } catch (e) {
console.log(`[Preferences] Error updating user.`, e); console.log(`[Preferences] Error updating user.`, e);
} }
@ -281,7 +361,9 @@ const UserTable = (props) => {
const handleDeleteClick = async (user) => { const handleDeleteClick = async (user) => {
try { try {
await userManager.delete(user.baseUrl); await userManager.delete(user.baseUrl);
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`); console.debug(
`[Preferences] User ${user.username} for ${user.baseUrl} deleted`
);
} catch (e) { } catch (e) {
console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e); console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
} }
@ -291,38 +373,59 @@ const UserTable = (props) => {
<Table size="small" aria-label={t("prefs_users_table")}> <Table size="small" aria-label={t("prefs_users_table")}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell> <TableCell sx={{ paddingLeft: 0 }}>
{t("prefs_users_table_user_header")}
</TableCell>
<TableCell>{t("prefs_users_table_base_url_header")}</TableCell> <TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
<TableCell/> <TableCell />
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{props.users?.map(user => ( {props.users?.map((user) => (
<TableRow <TableRow
key={user.baseUrl} key={user.baseUrl}
sx={{'&:last-child td, &:last-child th': {border: 0}}} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
> >
<TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell> <TableCell
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell> component="th"
scope="row"
sx={{ paddingLeft: 0 }}
aria-label={t("prefs_users_table_user_header")}
>
{user.username}
</TableCell>
<TableCell aria-label={t("prefs_users_table_base_url_header")}>
{user.baseUrl}
</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}> <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{(!session.exists() || user.baseUrl !== config.base_url) && {(!session.exists() || user.baseUrl !== config.base_url) && (
<> <>
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> <IconButton
<EditIcon/> onClick={() => handleEditClick(user)}
aria-label={t("prefs_users_edit_button")}
>
<EditIcon />
</IconButton> </IconButton>
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> <IconButton
<CloseIcon/> onClick={() => handleDeleteClick(user)}
aria-label={t("prefs_users_delete_button")}
>
<CloseIcon />
</IconButton> </IconButton>
</> </>
} )}
{session.exists() && user.baseUrl === config.base_url && {session.exists() && user.baseUrl === config.base_url && (
<Tooltip title={t("prefs_users_table_cannot_delete_or_edit")}> <Tooltip title={t("prefs_users_table_cannot_delete_or_edit")}>
<span> <span>
<IconButton disabled><EditIcon/></IconButton> <IconButton disabled>
<IconButton disabled><CloseIcon/></IconButton> <EditIcon />
</IconButton>
<IconButton disabled>
<CloseIcon />
</IconButton>
</span> </span>
</Tooltip> </Tooltip>
} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@ -344,25 +447,29 @@ const UserDialog = (props) => {
const [baseUrl, setBaseUrl] = useState(""); const [baseUrl, setBaseUrl] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const editMode = props.user !== null; const editMode = props.user !== null;
const addButtonEnabled = (() => { const addButtonEnabled = (() => {
if (editMode) { if (editMode) {
return username.length > 0 && password.length > 0; return username.length > 0 && password.length > 0;
} }
const baseUrlValid = validUrl(baseUrl); const baseUrlValid = validUrl(baseUrl);
const baseUrlExists = props.users?.map(user => user.baseUrl).includes(baseUrl); const baseUrlExists = props.users
return baseUrlValid ?.map((user) => user.baseUrl)
&& !baseUrlExists .includes(baseUrl);
&& username.length > 0 return (
&& password.length > 0; baseUrlValid &&
!baseUrlExists &&
username.length > 0 &&
password.length > 0
);
})(); })();
const handleSubmit = async () => { const handleSubmit = async () => {
props.onSubmit({ props.onSubmit({
baseUrl: baseUrl, baseUrl: baseUrl,
username: username, username: username,
password: password password: password,
}) });
}; };
useEffect(() => { useEffect(() => {
if (editMode) { if (editMode) {
@ -373,20 +480,26 @@ const UserDialog = (props) => {
}, [editMode, props.user]); }, [editMode, props.user]);
return ( return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}> <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")}</DialogTitle> <DialogTitle>
{editMode
? t("prefs_users_dialog_title_edit")
: t("prefs_users_dialog_title_add")}
</DialogTitle>
<DialogContent> <DialogContent>
{!editMode && <TextField {!editMode && (
<TextField
autoFocus autoFocus
margin="dense" margin="dense"
id="baseUrl" id="baseUrl"
label={t("prefs_users_dialog_base_url_label")} label={t("prefs_users_dialog_base_url_label")}
aria-label={t("prefs_users_dialog_base_url_label")} aria-label={t("prefs_users_dialog_base_url_label")}
value={baseUrl} value={baseUrl}
onChange={ev => setBaseUrl(ev.target.value)} onChange={(ev) => setBaseUrl(ev.target.value)}
type="url" type="url"
fullWidth fullWidth
variant="standard" variant="standard"
/>} />
)}
<TextField <TextField
autoFocus={editMode} autoFocus={editMode}
margin="dense" margin="dense"
@ -394,7 +507,7 @@ const UserDialog = (props) => {
label={t("prefs_users_dialog_username_label")} label={t("prefs_users_dialog_username_label")}
aria-label={t("prefs_users_dialog_username_label")} aria-label={t("prefs_users_dialog_username_label")}
value={username} value={username}
onChange={ev => setUsername(ev.target.value)} onChange={(ev) => setUsername(ev.target.value)}
type="text" type="text"
fullWidth fullWidth
variant="standard" variant="standard"
@ -406,14 +519,16 @@ const UserDialog = (props) => {
aria-label={t("prefs_users_dialog_password_label")} aria-label={t("prefs_users_dialog_password_label")}
type="password" type="password"
value={password} value={password}
onChange={ev => setPassword(ev.target.value)} onChange={(ev) => setPassword(ev.target.value)}
fullWidth fullWidth
variant="standard" variant="standard"
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={props.onCancel}>{t("common_cancel")}</Button> <Button onClick={props.onCancel}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>{editMode ? t("common_save") : t("common_add")}</Button> <Button onClick={handleSubmit} disabled={!addButtonEnabled}>
{editMode ? t("common_save") : t("common_add")}
</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );
@ -422,12 +537,12 @@ const UserDialog = (props) => {
const Appearance = () => { const Appearance = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Card sx={{p: 3}} aria-label={t("prefs_appearance_title")}> <Card sx={{ p: 3 }} aria-label={t("prefs_appearance_title")}>
<Typography variant="h5" sx={{marginBottom: 2}}> <Typography variant="h5" sx={{ marginBottom: 2 }}>
{t("prefs_appearance_title")} {t("prefs_appearance_title")}
</Typography> </Typography>
<PrefGroup> <PrefGroup>
<Language/> <Language />
</PrefGroup> </PrefGroup>
</Card> </Card>
); );
@ -440,7 +555,26 @@ const Language = () => {
// Country flags are displayed using emoji. Emoji rendering is handled by platform fonts. // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts.
// Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows. // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows.
const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇵🇱", "🇺🇦", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3); const randomFlags = shuffle([
"🇬🇧",
"🇺🇸",
"🇪🇸",
"🇫🇷",
"🇧🇬",
"🇨🇿",
"🇩🇪",
"🇵🇱",
"🇺🇦",
"🇨🇳",
"🇮🇹",
"🇭🇺",
"🇧🇷",
"🇳🇱",
"🇮🇩",
"🇯🇵",
"🇷🇺",
"🇹🇷",
]).slice(0, 3);
const showFlags = !navigator.userAgent.includes("Windows"); const showFlags = !navigator.userAgent.includes("Windows");
let title = t("prefs_appearance_language_title"); let title = t("prefs_appearance_language_title");
if (showFlags) { if (showFlags) {
@ -450,7 +584,7 @@ const Language = () => {
const handleChange = async (ev) => { const handleChange = async (ev) => {
await i18n.changeLanguage(ev.target.value); await i18n.changeLanguage(ev.target.value);
await maybeUpdateAccountSettings({ await maybeUpdateAccountSettings({
language: ev.target.value language: ev.target.value,
}); });
}; };
@ -488,7 +622,7 @@ const Language = () => {
</Select> </Select>
</FormControl> </FormControl>
</Pref> </Pref>
) );
}; };
const Reservations = () => { const Reservations = () => {
@ -501,27 +635,32 @@ const Reservations = () => {
return <></>; return <></>;
} }
const reservations = account.reservations || []; const reservations = account.reservations || [];
const limitReached = account.role === Role.USER && account.stats.reservations_remaining === 0; const limitReached =
account.role === Role.USER && account.stats.reservations_remaining === 0;
const handleAddClick = () => { const handleAddClick = () => {
setDialogKey(prev => prev+1); setDialogKey((prev) => prev + 1);
setDialogOpen(true); setDialogOpen(true);
}; };
return ( return (
<Card sx={{ padding: 1 }} aria-label={t("prefs_reservations_title")}> <Card sx={{ padding: 1 }} aria-label={t("prefs_reservations_title")}>
<CardContent sx={{ paddingBottom: 1 }}> <CardContent sx={{ paddingBottom: 1 }}>
<Typography variant="h5" sx={{marginBottom: 2}}> <Typography variant="h5" sx={{ marginBottom: 2 }}>
{t("prefs_reservations_title")} {t("prefs_reservations_title")}
</Typography> </Typography>
<Paragraph> <Paragraph>{t("prefs_reservations_description")}</Paragraph>
{t("prefs_reservations_description")} {reservations.length > 0 && (
</Paragraph> <ReservationsTable reservations={reservations} />
{reservations.length > 0 && <ReservationsTable reservations={reservations}/>} )}
{limitReached && <Alert severity="info">{t("prefs_reservations_limit_reached")}</Alert>} {limitReached && (
<Alert severity="info">{t("prefs_reservations_limit_reached")}</Alert>
)}
</CardContent> </CardContent>
<CardActions> <CardActions>
<Button onClick={handleAddClick} disabled={limitReached}>{t("prefs_reservations_add_button")}</Button> <Button onClick={handleAddClick} disabled={limitReached}>
{t("prefs_reservations_add_button")}
</Button>
<ReserveAddDialog <ReserveAddDialog
key={`reservationAddDialog${dialogKey}`} key={`reservationAddDialog${dialogKey}`}
open={dialogOpen} open={dialogOpen}
@ -540,18 +679,24 @@ const ReservationsTable = (props) => {
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { subscriptions } = useOutletContext(); const { subscriptions } = useOutletContext();
const localSubscriptions = (subscriptions?.length > 0) const localSubscriptions =
? Object.assign({}, ...subscriptions.filter(s => s.baseUrl === config.base_url).map(s => ({[s.topic]: s}))) subscriptions?.length > 0
? Object.assign(
{},
...subscriptions
.filter((s) => s.baseUrl === config.base_url)
.map((s) => ({ [s.topic]: s }))
)
: {}; : {};
const handleEditClick = (reservation) => { const handleEditClick = (reservation) => {
setDialogKey(prev => prev+1); setDialogKey((prev) => prev + 1);
setDialogReservation(reservation); setDialogReservation(reservation);
setEditDialogOpen(true); setEditDialogOpen(true);
}; };
const handleDeleteClick = async (reservation) => { const handleDeleteClick = async (reservation) => {
setDialogKey(prev => prev+1); setDialogKey((prev) => prev + 1);
setDialogReservation(reservation); setDialogReservation(reservation);
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
}; };
@ -564,57 +709,90 @@ const ReservationsTable = (props) => {
<Table size="small" aria-label={t("prefs_reservations_table")}> <Table size="small" aria-label={t("prefs_reservations_table")}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell sx={{paddingLeft: 0}}>{t("prefs_reservations_table_topic_header")}</TableCell> <TableCell sx={{ paddingLeft: 0 }}>
{t("prefs_reservations_table_topic_header")}
</TableCell>
<TableCell>{t("prefs_reservations_table_access_header")}</TableCell> <TableCell>{t("prefs_reservations_table_access_header")}</TableCell>
<TableCell/> <TableCell />
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{props.reservations.map(reservation => ( {props.reservations.map((reservation) => (
<TableRow <TableRow
key={reservation.topic} key={reservation.topic}
sx={{'&:last-child td, &:last-child th': { border: 0 }}} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell
component="th"
scope="row"
sx={{ paddingLeft: 0 }}
aria-label={t("prefs_reservations_table_topic_header")}
> >
<TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_reservations_table_topic_header")}>
{reservation.topic} {reservation.topic}
</TableCell> </TableCell>
<TableCell aria-label={t("prefs_reservations_table_access_header")}> <TableCell aria-label={t("prefs_reservations_table_access_header")}>
{reservation.everyone === Permission.READ_WRITE && {reservation.everyone === Permission.READ_WRITE && (
<> <>
<PermissionReadWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/> <PermissionReadWrite
size="small"
sx={{ verticalAlign: "bottom", mr: 1.5 }}
/>
{t("prefs_reservations_table_everyone_read_write")} {t("prefs_reservations_table_everyone_read_write")}
</> </>
} )}
{reservation.everyone === Permission.READ_ONLY && {reservation.everyone === Permission.READ_ONLY && (
<> <>
<PermissionRead size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/> <PermissionRead
size="small"
sx={{ verticalAlign: "bottom", mr: 1.5 }}
/>
{t("prefs_reservations_table_everyone_read_only")} {t("prefs_reservations_table_everyone_read_only")}
</> </>
} )}
{reservation.everyone === Permission.WRITE_ONLY && {reservation.everyone === Permission.WRITE_ONLY && (
<> <>
<PermissionWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/> <PermissionWrite
size="small"
sx={{ verticalAlign: "bottom", mr: 1.5 }}
/>
{t("prefs_reservations_table_everyone_write_only")} {t("prefs_reservations_table_everyone_write_only")}
</> </>
} )}
{reservation.everyone === Permission.DENY_ALL && {reservation.everyone === Permission.DENY_ALL && (
<> <>
<PermissionDenyAll size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/> <PermissionDenyAll
size="small"
sx={{ verticalAlign: "bottom", mr: 1.5 }}
/>
{t("prefs_reservations_table_everyone_deny_all")} {t("prefs_reservations_table_everyone_deny_all")}
</> </>
} )}
</TableCell> </TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}> <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{!localSubscriptions[reservation.topic] && {!localSubscriptions[reservation.topic] && (
<Tooltip title={t("prefs_reservations_table_click_to_subscribe")}> <Tooltip
<Chip icon={<Info/>} onClick={() => handleSubscribeClick(reservation)} label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined"/> title={t("prefs_reservations_table_click_to_subscribe")}
>
<Chip
icon={<Info />}
onClick={() => handleSubscribeClick(reservation)}
label={t("prefs_reservations_table_not_subscribed")}
color="primary"
variant="outlined"
/>
</Tooltip> </Tooltip>
} )}
<IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}> <IconButton
<EditIcon/> onClick={() => handleEditClick(reservation)}
aria-label={t("prefs_reservations_edit_button")}
>
<EditIcon />
</IconButton> </IconButton>
<IconButton onClick={() => handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}> <IconButton
<CloseIcon/> onClick={() => handleDeleteClick(reservation)}
aria-label={t("prefs_reservations_delete_button")}
>
<CloseIcon />
</IconButton> </IconButton>
</TableCell> </TableCell>
</TableRow> </TableRow>

View file

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from "react";
import {useContext, useEffect, useRef, useState} from 'react'; import { useContext, useEffect, useRef, useState } from "react";
import theme from "./theme"; import theme from "./theme";
import { import {
Checkbox, Checkbox,
@ -10,7 +10,7 @@ import {
Link, Link,
Select, Select,
Tooltip, Tooltip,
useMediaQuery useMediaQuery,
} from "@mui/material"; } from "@mui/material";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import priority1 from "../img/priority-1.svg"; import priority1 from "../img/priority-1.svg";
@ -24,22 +24,29 @@ import DialogContent from "@mui/material/DialogContent";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon'; import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
import {Close} from "@mui/icons-material"; import { Close } from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import {formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils"; import {
formatBytes,
maybeWithAuth,
topicShortUrl,
topicUrl,
validTopic,
validUrl,
} from "../app/utils";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import AttachmentIcon from "./AttachmentIcon"; import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import api from "../app/Api"; import api from "../app/Api";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker"; import EmojiPicker from "./EmojiPicker";
import {Trans, useTranslation} from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import {UnauthorizedError} from "../app/errors"; import { UnauthorizedError } from "../app/errors";
import {AccountContext} from "./App"; import { AccountContext } from "./App";
const PublishDialog = (props) => { const PublishDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -82,10 +89,10 @@ const PublishDialog = (props) => {
const [sendButtonEnabled, setSendButtonEnabled] = useState(true); const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
const open = !!props.openMode; const open = !!props.openMode;
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
useEffect(() => { useEffect(() => {
window.addEventListener('dragenter', () => { window.addEventListener("dragenter", () => {
props.onDragEnter(); props.onDragEnter();
setDropZone(true); setDropZone(true);
}); });
@ -109,7 +116,7 @@ const PublishDialog = (props) => {
const updateBaseUrl = (newVal) => { const updateBaseUrl = (newVal) => {
if (validUrl(newVal)) { if (validUrl(newVal)) {
setBaseUrl(newVal.replace(/\/$/, '')); // strip traililng slash after https?:// setBaseUrl(newVal.replace(/\/$/, "")); // strip traililng slash after https?://
} else { } else {
setBaseUrl(newVal); setBaseUrl(newVal);
} }
@ -145,19 +152,24 @@ const PublishDialog = (props) => {
url.searchParams.append("delay", delay.trim()); url.searchParams.append("delay", delay.trim());
} }
if (attachFile && message.trim()) { if (attachFile && message.trim()) {
url.searchParams.append("message", message.replaceAll("\n", "\\n").trim()); url.searchParams.append(
"message",
message.replaceAll("\n", "\\n").trim()
);
} }
const body = (attachFile) ? attachFile : message; const body = attachFile ? attachFile : message;
try { try {
const user = await userManager.get(baseUrl); const user = await userManager.get(baseUrl);
const headers = maybeWithAuth({}, user); const headers = maybeWithAuth({}, user);
const progressFn = (ev) => { const progressFn = (ev) => {
if (ev.loaded > 0 && ev.total > 0) { if (ev.loaded > 0 && ev.total > 0) {
setStatus(t("publish_dialog_progress_uploading_detail", { setStatus(
t("publish_dialog_progress_uploading_detail", {
loaded: formatBytes(ev.loaded), loaded: formatBytes(ev.loaded),
total: formatBytes(ev.total), total: formatBytes(ev.total),
percent: Math.round(ev.loaded * 100.0 / ev.total) percent: Math.round((ev.loaded * 100.0) / ev.total),
})); })
);
} else { } else {
setStatus(t("publish_dialog_progress_uploading")); setStatus(t("publish_dialog_progress_uploading"));
} }
@ -172,7 +184,11 @@ const PublishDialog = (props) => {
setActiveRequest(null); setActiveRequest(null);
} }
} catch (e) { } catch (e) {
setStatus(<Typography sx={{color: 'error.main', maxWidth: "400px"}}>{e}</Typography>); setStatus(
<Typography sx={{ color: "error.main", maxWidth: "400px" }}>
{e}
</Typography>
);
setActiveRequest(null); setActiveRequest(null);
} }
}; };
@ -182,17 +198,28 @@ const PublishDialog = (props) => {
const account = await accountApi.get(); const account = await accountApi.get();
const fileSizeLimit = account.limits.attachment_file_size ?? 0; const fileSizeLimit = account.limits.attachment_file_size ?? 0;
const remainingBytes = account.stats.attachment_total_size_remaining; const remainingBytes = account.stats.attachment_total_size_remaining;
const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit; const fileSizeLimitReached =
fileSizeLimit > 0 && file.size > fileSizeLimit;
const quotaReached = remainingBytes > 0 && file.size > remainingBytes; const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
if (fileSizeLimitReached && quotaReached) { if (fileSizeLimitReached && quotaReached) {
return setAttachFileError(t("publish_dialog_attachment_limits_file_and_quota_reached", { return setAttachFileError(
t("publish_dialog_attachment_limits_file_and_quota_reached", {
fileSizeLimit: formatBytes(fileSizeLimit), fileSizeLimit: formatBytes(fileSizeLimit),
remainingBytes: formatBytes(remainingBytes) remainingBytes: formatBytes(remainingBytes),
})); })
);
} else if (fileSizeLimitReached) { } else if (fileSizeLimitReached) {
return setAttachFileError(t("publish_dialog_attachment_limits_file_reached", { fileSizeLimit: formatBytes(fileSizeLimit) })); return setAttachFileError(
t("publish_dialog_attachment_limits_file_reached", {
fileSizeLimit: formatBytes(fileSizeLimit),
})
);
} else if (quotaReached) { } else if (quotaReached) {
return setAttachFileError(t("publish_dialog_attachment_limits_quota_reached", { remainingBytes: formatBytes(remainingBytes) })); return setAttachFileError(
t("publish_dialog_attachment_limits_quota_reached", {
remainingBytes: formatBytes(remainingBytes),
})
);
} }
setAttachFileError(""); setAttachFileError("");
} catch (e) { } catch (e) {
@ -238,7 +265,7 @@ const PublishDialog = (props) => {
}; };
const handleEmojiPick = (emoji) => { const handleEmojiPick = (emoji) => {
setTags(tags => (tags.trim()) ? `${tags.trim()}, ${emoji}` : emoji); setTags((tags) => (tags.trim() ? `${tags.trim()}, ${emoji}` : emoji));
}; };
const handleEmojiClose = () => { const handleEmojiClose = () => {
@ -250,37 +277,55 @@ const PublishDialog = (props) => {
2: { label: t("publish_dialog_priority_low"), file: priority2 }, 2: { label: t("publish_dialog_priority_low"), file: priority2 },
3: { label: t("publish_dialog_priority_default"), file: priority3 }, 3: { label: t("publish_dialog_priority_default"), file: priority3 },
4: { label: t("publish_dialog_priority_high"), file: priority4 }, 4: { label: t("publish_dialog_priority_high"), file: priority4 },
5: { label: t("publish_dialog_priority_max"), file: priority5 } 5: { label: t("publish_dialog_priority_max"), file: priority5 },
}; };
return ( return (
<> <>
{dropZone && <DropArea {dropZone && (
<DropArea
onDrop={handleAttachFileDrop} onDrop={handleAttachFileDrop}
onDragLeave={handleAttachFileDragLeave}/> onDragLeave={handleAttachFileDragLeave}
} />
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}> )}
<DialogTitle>{(baseUrl && topic) ? t("publish_dialog_title_topic", { topic: topicShortUrl(baseUrl, topic) }) : t("publish_dialog_title_no_topic")}</DialogTitle> <Dialog
maxWidth="md"
open={open}
onClose={props.onCancel}
fullScreen={fullScreen}
>
<DialogTitle>
{baseUrl && topic
? t("publish_dialog_title_topic", {
topic: topicShortUrl(baseUrl, topic),
})
: t("publish_dialog_title_no_topic")}
</DialogTitle>
<DialogContent> <DialogContent>
{dropZone && <DropBox/>} {dropZone && <DropBox />}
{showTopicUrl && {showTopicUrl && (
<ClosableRow closable={!!props.baseUrl && !!props.topic} disabled={disabled} closeLabel={t("publish_dialog_topic_reset")} onClose={() => { <ClosableRow
closable={!!props.baseUrl && !!props.topic}
disabled={disabled}
closeLabel={t("publish_dialog_topic_reset")}
onClose={() => {
setBaseUrl(props.baseUrl); setBaseUrl(props.baseUrl);
setTopic(props.topic); setTopic(props.topic);
setShowTopicUrl(false); setShowTopicUrl(false);
}}> }}
>
<TextField <TextField
margin="dense" margin="dense"
label={t("publish_dialog_base_url_label")} label={t("publish_dialog_base_url_label")}
placeholder={t("publish_dialog_base_url_placeholder")} placeholder={t("publish_dialog_base_url_placeholder")}
value={baseUrl} value={baseUrl}
onChange={ev => updateBaseUrl(ev.target.value)} onChange={(ev) => updateBaseUrl(ev.target.value)}
disabled={disabled} disabled={disabled}
type="url" type="url"
variant="standard" variant="standard"
sx={{flexGrow: 1, marginRight: 1}} sx={{ flexGrow: 1, marginRight: 1 }}
inputProps={{ inputProps={{
"aria-label": t("publish_dialog_base_url_label") "aria-label": t("publish_dialog_base_url_label"),
}} }}
/> />
<TextField <TextField
@ -288,30 +333,30 @@ const PublishDialog = (props) => {
label={t("publish_dialog_topic_label")} label={t("publish_dialog_topic_label")}
placeholder={t("publish_dialog_topic_placeholder")} placeholder={t("publish_dialog_topic_placeholder")}
value={topic} value={topic}
onChange={ev => setTopic(ev.target.value)} onChange={(ev) => setTopic(ev.target.value)}
disabled={disabled} disabled={disabled}
type="text" type="text"
variant="standard" variant="standard"
autoFocus={!messageFocused} autoFocus={!messageFocused}
sx={{flexGrow: 1}} sx={{ flexGrow: 1 }}
inputProps={{ inputProps={{
"aria-label": t("publish_dialog_topic_label") "aria-label": t("publish_dialog_topic_label"),
}} }}
/> />
</ClosableRow> </ClosableRow>
} )}
<TextField <TextField
margin="dense" margin="dense"
label={t("publish_dialog_title_label")} label={t("publish_dialog_title_label")}
placeholder={t("publish_dialog_title_placeholder")} placeholder={t("publish_dialog_title_placeholder")}
value={title} value={title}
onChange={ev => setTitle(ev.target.value)} onChange={(ev) => setTitle(ev.target.value)}
disabled={disabled} disabled={disabled}
type="text" type="text"
fullWidth fullWidth
variant="standard" variant="standard"
inputProps={{ inputProps={{
"aria-label": t("publish_dialog_title_label") "aria-label": t("publish_dialog_title_label"),
}} }}
/> />
<TextField <TextField
@ -319,7 +364,7 @@ const PublishDialog = (props) => {
label={t("publish_dialog_message_label")} label={t("publish_dialog_message_label")}
placeholder={t("publish_dialog_message_placeholder")} placeholder={t("publish_dialog_message_placeholder")}
value={message} value={message}
onChange={ev => setMessage(ev.target.value)} onChange={(ev) => setMessage(ev.target.value)}
disabled={disabled} disabled={disabled}
type="text" type="text"
variant="standard" variant="standard"
@ -328,38 +373,42 @@ const PublishDialog = (props) => {
fullWidth fullWidth
multiline multiline
inputProps={{ inputProps={{
"aria-label": t("publish_dialog_message_label") "aria-label": t("publish_dialog_message_label"),
}} }}
/> />
<div style={{display: 'flex'}}> <div style={{ display: "flex" }}>
<EmojiPicker <EmojiPicker
anchorEl={emojiPickerAnchorEl} anchorEl={emojiPickerAnchorEl}
onEmojiPick={handleEmojiPick} onEmojiPick={handleEmojiPick}
onClose={handleEmojiClose} onClose={handleEmojiClose}
/> />
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}> <DialogIconButton
<InsertEmoticonIcon/> disabled={disabled}
onClick={handleEmojiClick}
aria-label={t("publish_dialog_emoji_picker_show")}
>
<InsertEmoticonIcon />
</DialogIconButton> </DialogIconButton>
<TextField <TextField
margin="dense" margin="dense"
label={t("publish_dialog_tags_label")} label={t("publish_dialog_tags_label")}
placeholder={t("publish_dialog_tags_placeholder")} placeholder={t("publish_dialog_tags_placeholder")}
value={tags} value={tags}
onChange={ev => setTags(ev.target.value)} onChange={(ev) => setTags(ev.target.value)}
disabled={disabled} disabled={disabled}
type="text" type="text"
variant="standard" variant="standard"
sx={{flexGrow: 1, marginRight: 1}} sx={{ flexGrow: 1, marginRight: 1 }}
inputProps={{ inputProps={{
"aria-label": t("publish_dialog_tags_label") "aria-label": t("publish_dialog_tags_label"),
}} }}
/> />
<FormControl <FormControl
variant="standard" variant="standard"
margin="dense" margin="dense"
sx={{minWidth: 170, maxWidth: 300, flexGrow: 1}} sx={{ minWidth: 170, maxWidth: 300, flexGrow: 1 }}
> >
<InputLabel/> <InputLabel />
<Select <Select
label={t("publish_dialog_priority_label")} label={t("publish_dialog_priority_label")}
margin="dense" margin="dense"
@ -367,73 +416,93 @@ const PublishDialog = (props) => {
onChange={(ev) => setPriority(ev.target.value)} onChange={(ev) => setPriority(ev.target.value)}
disabled={disabled} disabled={disabled}
inputProps={{ inputProps={{
"aria-label": t("publish_dialog_priority_label") "aria-label": t("publish_dialog_priority_label"),
}} }}
> >
{[5,4,3,2,1].map(priority => {[5, 4, 3, 2, 1].map((priority) => (
<MenuItem key={`priorityMenuItem${priority}`} value={priority} aria-label={t("notifications_priority_x", { priority: priority })}> <MenuItem
<div style={{ display: 'flex', alignItems: 'center' }}> key={`priorityMenuItem${priority}`}
<img src={priorities[priority].file} style={{marginRight: "8px"}} alt={t("notifications_priority_x", { priority: priority })}/> value={priority}
aria-label={t("notifications_priority_x", {
priority: priority,
})}
>
<div style={{ display: "flex", alignItems: "center" }}>
<img
src={priorities[priority].file}
style={{ marginRight: "8px" }}
alt={t("notifications_priority_x", {
priority: priority,
})}
/>
<div>{priorities[priority].label}</div> <div>{priorities[priority].label}</div>
</div> </div>
</MenuItem> </MenuItem>
)} ))}
</Select> </Select>
</FormControl> </FormControl>
</div> </div>
{showClickUrl && {showClickUrl && (
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_click_reset")} onClose={() => { <ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_click_reset")}
onClose={() => {
setClickUrl(""); setClickUrl("");
setShowClickUrl(false); setShowClickUrl(false);
}}> }}
>
<TextField <TextField
margin="dense" margin="dense"
label={t("publish_dialog_click_label")} label={t("publish_dialog_click_label")}
placeholder={t("publish_dialog_click_placeholder")} placeholder={t("publish_dialog_click_placeholder")}
value={clickUrl} value={clickUrl}
onChange={ev => setClickUrl(ev.target.value)} onChange={(ev) => setClickUrl(ev.target.value)}
disabled={disabled} disabled={disabled}
type="url" type="url"
fullWidth fullWidth
variant="standard" variant="standard"
inputProps={{ inputProps={{
"aria-label": t("publish_dialog_click_label") "aria-label": t("publish_dialog_click_label"),
}} }}
/> />
</ClosableRow> </ClosableRow>
} )}
{showEmail && {showEmail && (
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_email_reset")} onClose={() => { <ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_email_reset")}
onClose={() => {
setEmail(""); setEmail("");
setShowEmail(false); setShowEmail(false);
}}> }}
>
<TextField <TextField
margin="dense" margin="dense"
label={t("publish_dialog_email_label")} label={t("publish_dialog_email_label")}
placeholder={t("publish_dialog_email_placeholder")} placeholder={t("publish_dialog_email_placeholder")}
value={email} value={email}
onChange={ev => setEmail(ev.target.value)} onChange={(ev) => setEmail(ev.target.value)}
disabled={disabled} disabled={disabled}
type="email" type="email"
variant="standard" variant="standard"
fullWidth fullWidth
inputProps={{ inputProps={{
"aria-label": t("publish_dialog_email_label") "aria-label": t("publish_dialog_email_label"),
}} }}
/> />
</ClosableRow> </ClosableRow>
} )}
{showCall && {showCall && (
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_call_reset")} onClose={() => { <ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_call_reset")}
onClose={() => {
setCall(""); setCall("");
setShowCall(false); setShowCall(false);
}}> }}
<FormControl
fullWidth
variant="standard"
margin="dense"
> >
<InputLabel/> <FormControl fullWidth variant="standard" margin="dense">
<InputLabel />
<Select <Select
label={t("publish_dialog_call_label")} label={t("publish_dialog_call_label")}
margin="dense" margin="dense"
@ -441,31 +510,39 @@ const PublishDialog = (props) => {
onChange={(ev) => setCall(ev.target.value)} onChange={(ev) => setCall(ev.target.value)}
disabled={disabled} disabled={disabled}
inputProps={{ inputProps={{
"aria-label": t("publish_dialog_call_label") "aria-label": t("publish_dialog_call_label"),
}} }}
> >
{account?.phone_numbers?.map((phoneNumber, i) => {account?.phone_numbers?.map((phoneNumber, i) => (
<MenuItem key={`phoneNumberMenuItem${i}`} value={phoneNumber} aria-label={phoneNumber}> <MenuItem
key={`phoneNumberMenuItem${i}`}
value={phoneNumber}
aria-label={phoneNumber}
>
{t("publish_dialog_call_item", { number: phoneNumber })} {t("publish_dialog_call_item", { number: phoneNumber })}
</MenuItem> </MenuItem>
)} ))}
</Select> </Select>
</FormControl> </FormControl>
</ClosableRow> </ClosableRow>
} )}
{showAttachUrl && {showAttachUrl && (
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => { <ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_attach_reset")}
onClose={() => {
setAttachUrl(""); setAttachUrl("");
setFilename(""); setFilename("");
setFilenameEdited(false); setFilenameEdited(false);
setShowAttachUrl(false); setShowAttachUrl(false);
}}> }}
>
<TextField <TextField
margin="dense" margin="dense"
label={t("publish_dialog_attach_label")} label={t("publish_dialog_attach_label")}
placeholder={t("publish_dialog_attach_placeholder")} placeholder={t("publish_dialog_attach_placeholder")}
value={attachUrl} value={attachUrl}
onChange={ev => { onChange={(ev) => {
const url = ev.target.value; const url = ev.target.value;
setAttachUrl(url); setAttachUrl(url);
if (!filenameEdited) { if (!filenameEdited) {
@ -473,7 +550,7 @@ const PublishDialog = (props) => {
const u = new URL(url); const u = new URL(url);
const parts = u.pathname.split("/"); const parts = u.pathname.split("/");
if (parts.length > 0) { if (parts.length > 0) {
setFilename(parts[parts.length-1]); setFilename(parts[parts.length - 1]);
} }
} catch (e) { } catch (e) {
// Do nothing // Do nothing
@ -483,9 +560,9 @@ const PublishDialog = (props) => {
disabled={disabled} disabled={disabled}
type="url" type="url"
variant="standard" variant="standard"
sx={{flexGrow: 5, marginRight: 1}} sx={{ flexGrow: 5, marginRight: 1 }}
inputProps={{ inputProps={{
"aria-label": t("publish_dialog_attach_label") "aria-label": t("publish_dialog_attach_label"),
}} }}
/> />
<TextField <TextField
@ -493,28 +570,29 @@ const PublishDialog = (props) => {
label={t("publish_dialog_filename_label")} label={t("publish_dialog_filename_label")}
placeholder={t("publish_dialog_filename_placeholder")} placeholder={t("publish_dialog_filename_placeholder")}
value={filename} value={filename}
onChange={ev => { onChange={(ev) => {
setFilename(ev.target.value); setFilename(ev.target.value);
setFilenameEdited(true); setFilenameEdited(true);
}} }}
disabled={disabled} disabled={disabled}
type="text" type="text"
variant="standard" variant="standard"
sx={{flexGrow: 1}} sx={{ flexGrow: 1 }}
inputProps={{ inputProps={{
"aria-label": t("publish_dialog_filename_label") "aria-label": t("publish_dialog_filename_label"),
}} }}
/> />
</ClosableRow> </ClosableRow>
} )}
<input <input
type="file" type="file"
ref={attachFileInput} ref={attachFileInput}
onChange={handleAttachFileChanged} onChange={handleAttachFileChanged}
style={{ display: 'none' }} style={{ display: "none" }}
aria-hidden={true} aria-hidden={true}
/> />
{showAttachFile && <AttachmentBox {showAttachFile && (
<AttachmentBox
file={attachFile} file={attachFile}
filename={filename} filename={filename}
disabled={disabled} disabled={disabled}
@ -525,74 +603,179 @@ const PublishDialog = (props) => {
setAttachFileError(""); setAttachFileError("");
setFilename(""); setFilename("");
}} }}
/>} />
{showDelay && )}
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_delay_reset")} onClose={() => { {showDelay && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_delay_reset")}
onClose={() => {
setDelay(""); setDelay("");
setShowDelay(false); setShowDelay(false);
}}> }}
>
<TextField <TextField
margin="dense" margin="dense"
label={t("publish_dialog_delay_label")} label={t("publish_dialog_delay_label")}
placeholder={t("publish_dialog_delay_placeholder", { placeholder={t("publish_dialog_delay_placeholder", {
unixTimestamp: "1649029748", unixTimestamp: "1649029748",
relativeTime: "30m", relativeTime: "30m",
naturalLanguage: "tomorrow, 9am" naturalLanguage: "tomorrow, 9am",
})} })}
value={delay} value={delay}
onChange={ev => setDelay(ev.target.value)} onChange={(ev) => setDelay(ev.target.value)}
disabled={disabled} disabled={disabled}
type="text" type="text"
variant="standard" variant="standard"
fullWidth fullWidth
inputProps={{ inputProps={{
"aria-label": t("publish_dialog_delay_label") "aria-label": t("publish_dialog_delay_label"),
}} }}
/> />
</ClosableRow> </ClosableRow>
} )}
<Typography variant="body1" sx={{marginTop: 2, marginBottom: 1}}> <Typography variant="body1" sx={{ marginTop: 2, marginBottom: 1 }}>
{t("publish_dialog_other_features")} {t("publish_dialog_other_features")}
</Typography> </Typography>
<div> <div>
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showClickUrl && (
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} <Chip
{account?.phone_numbers?.length > 0 && !showCall && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_call_label")} aria-label={t("publish_dialog_chip_call_label")} onClick={() => { setShowCall(true); setCall(account.phone_numbers[0]); }} sx={{marginRight: 1, marginBottom: 1}}/>} clickable
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} disabled={disabled}
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} label={t("publish_dialog_chip_click_label")}
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>} aria-label={t("publish_dialog_chip_click_label")}
{!showTopicUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_topic_label")} aria-label={t("publish_dialog_chip_topic_label")} onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} onClick={() => setShowClickUrl(true)}
{account && !account?.phone_numbers && <Tooltip title={t("publish_dialog_chip_call_no_verified_numbers_tooltip")}><span><Chip clickable disabled label={t("publish_dialog_chip_call_label")} aria-label={t("publish_dialog_chip_call_label")} sx={{marginRight: 1, marginBottom: 1}}/></span></Tooltip>} sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showEmail && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_email_label")}
aria-label={t("publish_dialog_chip_email_label")}
onClick={() => setShowEmail(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{account?.phone_numbers?.length > 0 && !showCall && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_call_label")}
aria-label={t("publish_dialog_chip_call_label")}
onClick={() => {
setShowCall(true);
setCall(account.phone_numbers[0]);
}}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showAttachUrl && !showAttachFile && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_attach_url_label")}
aria-label={t("publish_dialog_chip_attach_url_label")}
onClick={() => setShowAttachUrl(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showAttachFile && !showAttachUrl && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_attach_file_label")}
aria-label={t("publish_dialog_chip_attach_file_label")}
onClick={() => handleAttachFileClick()}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showDelay && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_delay_label")}
aria-label={t("publish_dialog_chip_delay_label")}
onClick={() => setShowDelay(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showTopicUrl && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_topic_label")}
aria-label={t("publish_dialog_chip_topic_label")}
onClick={() => setShowTopicUrl(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{account && !account?.phone_numbers && (
<Tooltip
title={t(
"publish_dialog_chip_call_no_verified_numbers_tooltip"
)}
>
<span>
<Chip
clickable
disabled
label={t("publish_dialog_chip_call_label")}
aria-label={t("publish_dialog_chip_call_label")}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
</span>
</Tooltip>
)}
</div> </div>
<Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}> <Typography variant="body1" sx={{ marginTop: 1, marginBottom: 1 }}>
<Trans <Trans
i18nKey="publish_dialog_details_examples_description" i18nKey="publish_dialog_details_examples_description"
components={{ components={{
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/> docsLink: (
<Link
href="https://ntfy.sh/docs"
target="_blank"
rel="noopener"
/>
),
}} }}
/> />
</Typography> </Typography>
</DialogContent> </DialogContent>
<DialogFooter status={status}> <DialogFooter status={status}>
{activeRequest && <Button onClick={() => activeRequest.abort()}>{t("publish_dialog_button_cancel_sending")}</Button>} {activeRequest && (
{!activeRequest && <Button onClick={() => activeRequest.abort()}>
{t("publish_dialog_button_cancel_sending")}
</Button>
)}
{!activeRequest && (
<> <>
<FormControlLabel <FormControlLabel
label={t("publish_dialog_checkbox_publish_another")} label={t("publish_dialog_checkbox_publish_another")}
sx={{marginRight: 2}} sx={{ marginRight: 2 }}
control={ control={
<Checkbox <Checkbox
size="small" size="small"
checked={publishAnother} checked={publishAnother}
onChange={(ev) => setPublishAnother(ev.target.checked)} onChange={(ev) => setPublishAnother(ev.target.checked)}
inputProps={{ inputProps={{
"aria-label": t("publish_dialog_checkbox_publish_another") "aria-label": t(
}} /> "publish_dialog_checkbox_publish_another"
} /> ),
<Button onClick={props.onClose}>{t("publish_dialog_button_cancel")}</Button> }}
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>{t("publish_dialog_button_send")}</Button> />
</>
} }
/>
<Button onClick={props.onClose}>
{t("publish_dialog_button_cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>
{t("publish_dialog_button_send")}
</Button>
</>
)}
</DialogFooter> </DialogFooter>
</Dialog> </Dialog>
</> </>
@ -601,22 +784,27 @@ const PublishDialog = (props) => {
const Row = (props) => { const Row = (props) => {
return ( return (
<div style={{display: 'flex'}} role="row"> <div style={{ display: "flex" }} role="row">
{props.children} {props.children}
</div> </div>
); );
}; };
const ClosableRow = (props) => { const ClosableRow = (props) => {
const closable = (props.hasOwnProperty("closable")) ? props.closable : true; const closable = props.hasOwnProperty("closable") ? props.closable : true;
return ( return (
<Row> <Row>
{props.children} {props.children}
{closable && {closable && (
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={props.closeLabel}> <DialogIconButton
<Close/> disabled={props.disabled}
onClick={props.onClose}
sx={{ marginLeft: "6px" }}
aria-label={props.closeLabel}
>
<Close />
</DialogIconButton> </DialogIconButton>
} )}
</Row> </Row>
); );
}; };
@ -628,7 +816,7 @@ const DialogIconButton = (props) => {
color="inherit" color="inherit"
size="large" size="large"
edge="start" edge="start"
sx={{height: "45px", marginTop: "17px", ...sx}} sx={{ height: "45px", marginTop: "17px", ...sx }}
onClick={props.onClick} onClick={props.onClick}
disabled={props.disabled} disabled={props.disabled}
aria-label={props["aria-label"]} aria-label={props["aria-label"]}
@ -643,17 +831,19 @@ const AttachmentBox = (props) => {
const file = props.file; const file = props.file;
return ( return (
<> <>
<Typography variant="body1" sx={{marginTop: 2}}> <Typography variant="body1" sx={{ marginTop: 2 }}>
{t("publish_dialog_attached_file_title")} {t("publish_dialog_attached_file_title")}
</Typography> </Typography>
<Box sx={{ <Box
display: 'flex', sx={{
alignItems: 'center', display: "flex",
alignItems: "center",
padding: 0.5, padding: 0.5,
borderRadius: '4px', borderRadius: "4px",
}}> }}
<AttachmentIcon type={file.type}/> >
<Box sx={{ marginLeft: 1, textAlign: 'left' }}> <AttachmentIcon type={file.type} />
<Box sx={{ marginLeft: 1, textAlign: "left" }}>
<ExpandingTextField <ExpandingTextField
minWidth={140} minWidth={140}
variant="body2" variant="body2"
@ -662,18 +852,28 @@ const AttachmentBox = (props) => {
onChange={(ev) => props.onChangeFilename(ev.target.value)} onChange={(ev) => props.onChangeFilename(ev.target.value)}
disabled={props.disabled} disabled={props.disabled}
/> />
<br/> <br />
<Typography variant="body2" sx={{ color: 'text.primary' }}> <Typography variant="body2" sx={{ color: "text.primary" }}>
{formatBytes(file.size)} {formatBytes(file.size)}
{props.error && {props.error && (
<Typography component="span" sx={{ color: 'error.main' }} aria-live="polite"> <Typography
{" "}({props.error}) component="span"
sx={{ color: "error.main" }}
aria-live="polite"
>
{" "}
({props.error})
</Typography> </Typography>
} )}
</Typography> </Typography>
</Box> </Box>
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={t("publish_dialog_attached_file_remove")}> <DialogIconButton
<Close/> disabled={props.disabled}
onClick={props.onClose}
sx={{ marginLeft: "6px" }}
aria-label={t("publish_dialog_attached_file_remove")}
>
<Close />
</DialogIconButton> </DialogIconButton>
</Box> </Box>
</> </>
@ -688,7 +888,9 @@ const ExpandingTextField = (props) => {
if (!boundingRect) { if (!boundingRect) {
return props.minWidth; return props.minWidth;
} }
return (boundingRect.width >= props.minWidth) ? Math.round(boundingRect.width) : props.minWidth; return boundingRect.width >= props.minWidth
? Math.round(boundingRect.width)
: props.minWidth;
}; };
useEffect(() => { useEffect(() => {
setTextWidth(determineTextWidth() + 5); setTextWidth(determineTextWidth() + 5);
@ -700,7 +902,7 @@ const ExpandingTextField = (props) => {
component="span" component="span"
variant={props.variant} variant={props.variant}
aria-hidden={true} aria-hidden={true}
sx={{position: "absolute", left: "-200%"}} sx={{ position: "absolute", left: "-200%" }}
> >
{props.value} {props.value}
</Typography> </Typography>
@ -712,15 +914,17 @@ const ExpandingTextField = (props) => {
type="text" type="text"
variant="standard" variant="standard"
sx={{ width: `${textWidth}px`, borderBottom: "none" }} sx={{ width: `${textWidth}px`, borderBottom: "none" }}
InputProps={{ style: { fontSize: theme.typography[props.variant].fontSize } }} InputProps={{
style: { fontSize: theme.typography[props.variant].fontSize },
}}
inputProps={{ inputProps={{
style: { paddingBottom: 0, paddingTop: 0 }, style: { paddingBottom: 0, paddingTop: 0 },
"aria-label": props.placeholder "aria-label": props.placeholder,
}} }}
disabled={props.disabled} disabled={props.disabled}
/> />
</> </>
) );
}; };
const DropArea = (props) => { const DropArea = (props) => {
@ -728,14 +932,14 @@ const DropArea = (props) => {
// This is where we could disallow certain files to be dragged in. // This is where we could disallow certain files to be dragged in.
// For now we allow all files. // For now we allow all files.
ev.dataTransfer.dropEffect = 'copy'; ev.dataTransfer.dropEffect = "copy";
ev.preventDefault(); ev.preventDefault();
}; };
return ( return (
<Box <Box
sx={{ sx={{
position: 'absolute', position: "absolute",
left: 0, left: 0,
top: 0, top: 0,
right: 0, right: 0,
@ -753,35 +957,39 @@ const DropArea = (props) => {
const DropBox = () => { const DropBox = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Box sx={{ <Box
position: 'absolute', sx={{
position: "absolute",
left: 0, left: 0,
top: 0, top: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
zIndex: 10000, zIndex: 10000,
backgroundColor: "#ffffffbb" backgroundColor: "#ffffffbb",
}}> }}
>
<Box <Box
sx={{ sx={{
position: 'absolute', position: "absolute",
border: '3px dashed #ccc', border: "3px dashed #ccc",
borderRadius: '5px', borderRadius: "5px",
left: "40px", left: "40px",
top: "40px", top: "40px",
right: "40px", right: "40px",
bottom: "40px", bottom: "40px",
zIndex: 10001, zIndex: 10001,
display: 'flex', display: "flex",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}} }}
> >
<Typography variant="h5">{t("publish_dialog_drop_file_here")}</Typography> <Typography variant="h5">
{t("publish_dialog_drop_file_here")}
</Typography>
</Box> </Box>
</Box> </Box>
); );
} };
PublishDialog.OPEN_MODE_DEFAULT = "default"; PublishDialog.OPEN_MODE_DEFAULT = "default";
PublishDialog.OPEN_MODE_DRAG = "drag"; PublishDialog.OPEN_MODE_DRAG = "drag";

View file

@ -1,40 +1,43 @@
import * as React from 'react'; import * as React from "react";
import {useState} from 'react'; import { useState } from "react";
import Button from '@mui/material/Button'; import Button from "@mui/material/Button";
import TextField from '@mui/material/TextField'; import TextField from "@mui/material/TextField";
import Dialog from '@mui/material/Dialog'; import Dialog from "@mui/material/Dialog";
import DialogContent from '@mui/material/DialogContent'; import DialogContent from "@mui/material/DialogContent";
import DialogContentText from '@mui/material/DialogContentText'; import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from "@mui/material/DialogTitle";
import {Alert, FormControl, Select, useMediaQuery} from "@mui/material"; import { Alert, FormControl, Select, useMediaQuery } from "@mui/material";
import theme from "./theme"; import theme from "./theme";
import {validTopic} from "../app/utils"; import { validTopic } from "../app/utils";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi, {Permission} from "../app/AccountApi"; import accountApi, { Permission } from "../app/AccountApi";
import ReserveTopicSelect from "./ReserveTopicSelect"; import ReserveTopicSelect from "./ReserveTopicSelect";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText"; import ListItemText from "@mui/material/ListItemText";
import {Check, DeleteForever} from "@mui/icons-material"; import { Check, DeleteForever } from "@mui/icons-material";
import {TopicReservedError, UnauthorizedError} from "../app/errors"; import { TopicReservedError, UnauthorizedError } from "../app/errors";
export const ReserveAddDialog = (props) => { export const ReserveAddDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [topic, setTopic] = useState(props.topic || ""); const [topic, setTopic] = useState(props.topic || "");
const [everyone, setEveryone] = useState(Permission.DENY_ALL); const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const allowTopicEdit = !props.topic; const allowTopicEdit = !props.topic;
const alreadyReserved = props.reservations.filter(r => r.topic === topic).length > 0; const alreadyReserved =
props.reservations.filter((r) => r.topic === topic).length > 0;
const submitButtonEnabled = validTopic(topic) && !alreadyReserved; const submitButtonEnabled = validTopic(topic) && !alreadyReserved;
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
await accountApi.upsertReservation(topic, everyone); await accountApi.upsertReservation(topic, everyone);
console.debug(`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`); console.debug(
`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`
);
} catch (e) { } catch (e) {
console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); console.log(`[ReserveAddDialog] Error adding topic reservation.`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
@ -51,33 +54,43 @@ export const ReserveAddDialog = (props) => {
}; };
return ( return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <Dialog
open={props.open}
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>{t("prefs_reservations_dialog_title_add")}</DialogTitle> <DialogTitle>{t("prefs_reservations_dialog_title_add")}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
{t("prefs_reservations_dialog_description")} {t("prefs_reservations_dialog_description")}
</DialogContentText> </DialogContentText>
{allowTopicEdit && <TextField {allowTopicEdit && (
<TextField
autoFocus autoFocus
margin="dense" margin="dense"
id="topic" id="topic"
label={t("prefs_reservations_dialog_topic_label")} label={t("prefs_reservations_dialog_topic_label")}
aria-label={t("prefs_reservations_dialog_topic_label")} aria-label={t("prefs_reservations_dialog_topic_label")}
value={topic} value={topic}
onChange={ev => setTopic(ev.target.value)} onChange={(ev) => setTopic(ev.target.value)}
type="url" type="url"
fullWidth fullWidth
variant="standard" variant="standard"
/>} />
)}
<ReserveTopicSelect <ReserveTopicSelect
value={everyone} value={everyone}
onChange={setEveryone} onChange={setEveryone}
sx={{mt: 1}} sx={{ mt: 1 }}
/> />
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button> <Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!submitButtonEnabled}>{t("common_add")}</Button> <Button onClick={handleSubmit} disabled={!submitButtonEnabled}>
{t("common_add")}
</Button>
</DialogFooter> </DialogFooter>
</Dialog> </Dialog>
); );
@ -86,13 +99,17 @@ export const ReserveAddDialog = (props) => {
export const ReserveEditDialog = (props) => { export const ReserveEditDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL); const [everyone, setEveryone] = useState(
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); props.reservation?.everyone || Permission.DENY_ALL
);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
await accountApi.upsertReservation(props.reservation.topic, everyone); await accountApi.upsertReservation(props.reservation.topic, everyone);
console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`); console.debug(
`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`
);
} catch (e) { } catch (e) {
console.log(`[ReserveEditDialog] Error updating topic reservation.`, e); console.log(`[ReserveEditDialog] Error updating topic reservation.`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
@ -106,7 +123,13 @@ export const ReserveEditDialog = (props) => {
}; };
return ( return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <Dialog
open={props.open}
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>{t("prefs_reservations_dialog_title_edit")}</DialogTitle> <DialogTitle>{t("prefs_reservations_dialog_title_edit")}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
@ -115,7 +138,7 @@ export const ReserveEditDialog = (props) => {
<ReserveTopicSelect <ReserveTopicSelect
value={everyone} value={everyone}
onChange={setEveryone} onChange={setEveryone}
sx={{mt: 1}} sx={{ mt: 1 }}
/> />
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
@ -130,12 +153,14 @@ export const ReserveDeleteDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [deleteMessages, setDeleteMessages] = useState(false); const [deleteMessages, setDeleteMessages] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
await accountApi.deleteReservation(props.topic, deleteMessages); await accountApi.deleteReservation(props.topic, deleteMessages);
console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`); console.debug(
`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`
);
} catch (e) { } catch (e) {
console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e); console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
@ -149,7 +174,13 @@ export const ReserveDeleteDialog = (props) => {
}; };
return ( return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <Dialog
open={props.open}
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>{t("prefs_reservations_dialog_title_delete")}</DialogTitle> <DialogTitle>{t("prefs_reservations_dialog_title_delete")}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
@ -161,39 +192,48 @@ export const ReserveDeleteDialog = (props) => {
onChange={(ev) => setDeleteMessages(ev.target.value)} onChange={(ev) => setDeleteMessages(ev.target.value)}
sx={{ sx={{
"& .MuiSelect-select": { "& .MuiSelect-select": {
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
paddingTop: "4px", paddingTop: "4px",
paddingBottom: "4px", paddingBottom: "4px",
} },
}} }}
> >
<MenuItem value={false}> <MenuItem value={false}>
<ListItemIcon><Check/></ListItemIcon> <ListItemIcon>
<ListItemText primary={t("reservation_delete_dialog_action_keep_title")}/> <Check />
</ListItemIcon>
<ListItemText
primary={t("reservation_delete_dialog_action_keep_title")}
/>
</MenuItem> </MenuItem>
<MenuItem value={true}> <MenuItem value={true}>
<ListItemIcon><DeleteForever/></ListItemIcon> <ListItemIcon>
<ListItemText primary={t("reservation_delete_dialog_action_delete_title")}/> <DeleteForever />
</ListItemIcon>
<ListItemText
primary={t("reservation_delete_dialog_action_delete_title")}
/>
</MenuItem> </MenuItem>
</Select> </Select>
</FormControl> </FormControl>
{!deleteMessages && {!deleteMessages && (
<Alert severity="info" sx={{ mt: 1 }}> <Alert severity="info" sx={{ mt: 1 }}>
{t("reservation_delete_dialog_action_keep_description")} {t("reservation_delete_dialog_action_keep_description")}
</Alert> </Alert>
} )}
{deleteMessages && {deleteMessages && (
<Alert severity="warning" sx={{ mt: 1 }}> <Alert severity="warning" sx={{ mt: 1 }}>
{t("reservation_delete_dialog_action_delete_description")} {t("reservation_delete_dialog_action_delete_description")}
</Alert> </Alert>
} )}
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button> <Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} color="error">{t("reservation_delete_dialog_submit_button")}</Button> <Button onClick={handleSubmit} color="error">
{t("reservation_delete_dialog_submit_button")}
</Button>
</DialogFooter> </DialogFooter>
</Dialog> </Dialog>
); );
}; };

View file

@ -1,30 +1,39 @@
import * as React from 'react'; import * as React from "react";
import {Lock, Public} from "@mui/icons-material"; import { Lock, Public } from "@mui/icons-material";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
export const PermissionReadWrite = React.forwardRef((props, ref) => { export const PermissionReadWrite = React.forwardRef((props, ref) => {
return <PermissionInternal icon={Public} ref={ref} {...props}/>; return <PermissionInternal icon={Public} ref={ref} {...props} />;
}); });
export const PermissionDenyAll = React.forwardRef((props, ref) => { export const PermissionDenyAll = React.forwardRef((props, ref) => {
return <PermissionInternal icon={Lock} ref={ref} {...props}/>; return <PermissionInternal icon={Lock} ref={ref} {...props} />;
}); });
export const PermissionRead = React.forwardRef((props, ref) => { export const PermissionRead = React.forwardRef((props, ref) => {
return <PermissionInternal icon={Public} text="R" ref={ref} {...props}/>; return <PermissionInternal icon={Public} text="R" ref={ref} {...props} />;
}); });
export const PermissionWrite = React.forwardRef((props, ref) => { export const PermissionWrite = React.forwardRef((props, ref) => {
return <PermissionInternal icon={Public} text="W" ref={ref} {...props}/>; return <PermissionInternal icon={Public} text="W" ref={ref} {...props} />;
}); });
const PermissionInternal = React.forwardRef((props, ref) => { const PermissionInternal = React.forwardRef((props, ref) => {
const size = props.size ?? "medium"; const size = props.size ?? "medium";
const Icon = props.icon; const Icon = props.icon;
return ( return (
<Box ref={ref} {...props} style={{ position: "relative", display: "inline-flex", verticalAlign: "middle", height: "24px" }}> <Box
<Icon fontSize={size} sx={{ color: "gray" }}/> ref={ref}
{props.text && {...props}
style={{
position: "relative",
display: "inline-flex",
verticalAlign: "middle",
height: "24px",
}}
>
<Icon fontSize={size} sx={{ color: "gray" }} />
{props.text && (
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
@ -35,12 +44,12 @@ const PermissionInternal = React.forwardRef((props, ref) => {
color: "gray", color: "gray",
width: "8px", width: "8px",
height: "8px", height: "8px",
marginTop: "3px" marginTop: "3px",
}} }}
> >
{props.text} {props.text}
</Box> </Box>
} )}
</Box> </Box>
); );
}); });

View file

@ -1,11 +1,16 @@
import * as React from 'react'; import * as React from "react";
import {FormControl, Select} from "@mui/material"; import { FormControl, Select } from "@mui/material";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText"; import ListItemText from "@mui/material/ListItemText";
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; import {
import {Permission} from "../app/AccountApi"; PermissionDenyAll,
PermissionRead,
PermissionReadWrite,
PermissionWrite,
} from "./ReserveIcons";
import { Permission } from "../app/AccountApi";
const ReserveTopicSelect = (props) => { const ReserveTopicSelect = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -18,28 +23,44 @@ const ReserveTopicSelect = (props) => {
aria-label={t("prefs_reservations_dialog_access_label")} aria-label={t("prefs_reservations_dialog_access_label")}
sx={{ sx={{
"& .MuiSelect-select": { "& .MuiSelect-select": {
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
paddingTop: "4px", paddingTop: "4px",
paddingBottom: "4px", paddingBottom: "4px",
} },
}} }}
> >
<MenuItem value={Permission.DENY_ALL}> <MenuItem value={Permission.DENY_ALL}>
<ListItemIcon><PermissionDenyAll/></ListItemIcon> <ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_deny_all")}/> <PermissionDenyAll />
</ListItemIcon>
<ListItemText
primary={t("prefs_reservations_table_everyone_deny_all")}
/>
</MenuItem> </MenuItem>
<MenuItem value={Permission.READ_ONLY}> <MenuItem value={Permission.READ_ONLY}>
<ListItemIcon><PermissionRead/></ListItemIcon> <ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_read_only")}/> <PermissionRead />
</ListItemIcon>
<ListItemText
primary={t("prefs_reservations_table_everyone_read_only")}
/>
</MenuItem> </MenuItem>
<MenuItem value={Permission.WRITE_ONLY}> <MenuItem value={Permission.WRITE_ONLY}>
<ListItemIcon><PermissionWrite/></ListItemIcon> <ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_write_only")}/> <PermissionWrite />
</ListItemIcon>
<ListItemText
primary={t("prefs_reservations_table_everyone_write_only")}
/>
</MenuItem> </MenuItem>
<MenuItem value={Permission.READ_WRITE}> <MenuItem value={Permission.READ_WRITE}>
<ListItemIcon><PermissionReadWrite/></ListItemIcon> <ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_read_write")}/> <PermissionReadWrite />
</ListItemIcon>
<ListItemText
primary={t("prefs_reservations_table_everyone_read_write")}
/>
</MenuItem> </MenuItem>
</Select> </Select>
</FormControl> </FormControl>

View file

@ -1,20 +1,20 @@
import * as React from 'react'; import * as React from "react";
import {useState} from 'react'; import { useState } from "react";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import routes from "./routes"; import routes from "./routes";
import session from "../app/Session"; import session from "../app/Session";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import {NavLink} from "react-router-dom"; import { NavLink } from "react-router-dom";
import AvatarBox from "./AvatarBox"; import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import {InputAdornment} from "@mui/material"; import { InputAdornment } from "@mui/material";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import {Visibility, VisibilityOff} from "@mui/icons-material"; import { Visibility, VisibilityOff } from "@mui/icons-material";
import {AccountCreateLimitReachedError, UserExistsError} from "../app/errors"; import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors";
const Signup = () => { const Signup = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -31,14 +31,16 @@ const Signup = () => {
try { try {
await accountApi.create(user.username, user.password); await accountApi.create(user.username, user.password);
const token = await accountApi.login(user); const token = await accountApi.login(user);
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); console.log(
`[Signup] User signup for user ${user.username} successful, token is ${token}`
);
session.store(user.username, token); session.store(user.username, token);
window.location.href = routes.app; window.location.href = routes.app;
} catch (e) { } catch (e) {
console.log(`[Signup] Signup for user ${user.username} failed`, e); console.log(`[Signup] Signup for user ${user.username} failed`, e);
if (e instanceof UserExistsError) { if (e instanceof UserExistsError) {
setError(t("signup_error_username_taken", { username: e.username })); setError(t("signup_error_username_taken", { username: e.username }));
} else if ((e instanceof AccountCreateLimitReachedError)) { } else if (e instanceof AccountCreateLimitReachedError) {
setError(t("signup_error_creation_limit_reached")); setError(t("signup_error_creation_limit_reached"));
} else { } else {
setError(e.message); setError(e.message);
@ -49,17 +51,22 @@ const Signup = () => {
if (!config.enable_signup) { if (!config.enable_signup) {
return ( return (
<AvatarBox> <AvatarBox>
<Typography sx={{ typography: 'h6' }}>{t("signup_disabled")}</Typography> <Typography sx={{ typography: "h6" }}>
{t("signup_disabled")}
</Typography>
</AvatarBox> </AvatarBox>
); );
} }
return ( return (
<AvatarBox> <AvatarBox>
<Typography sx={{ typography: 'h6' }}> <Typography sx={{ typography: "h6" }}>{t("signup_title")}</Typography>
{t("signup_title")} <Box
</Typography> component="form"
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}> onSubmit={handleSubmit}
noValidate
sx={{ mt: 1, maxWidth: 400 }}
>
<TextField <TextField
margin="dense" margin="dense"
required required
@ -68,7 +75,7 @@ const Signup = () => {
label={t("signup_form_username")} label={t("signup_form_username")}
name="username" name="username"
value={username} value={username}
onChange={ev => setUsername(ev.target.value.trim())} onChange={(ev) => setUsername(ev.target.value.trim())}
autoFocus autoFocus
/> />
<TextField <TextField
@ -81,7 +88,7 @@ const Signup = () => {
id="password" id="password"
autoComplete="new-password" autoComplete="new-password"
value={password} value={password}
onChange={ev => setPassword(ev.target.value.trim())} onChange={(ev) => setPassword(ev.target.value.trim())}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
@ -94,7 +101,7 @@ const Signup = () => {
{showPassword ? <VisibilityOff /> : <Visibility />} {showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
) ),
}} }}
/> />
<TextField <TextField
@ -107,7 +114,7 @@ const Signup = () => {
id="confirm" id="confirm"
autoComplete="new-password" autoComplete="new-password"
value={confirm} value={confirm}
onChange={ev => setConfirm(ev.target.value.trim())} onChange={(ev) => setConfirm(ev.target.value.trim())}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
@ -120,7 +127,7 @@ const Signup = () => {
{showConfirm ? <VisibilityOff /> : <Visibility />} {showConfirm ? <VisibilityOff /> : <Visibility />}
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
) ),
}} }}
/> />
<Button <Button
@ -128,31 +135,33 @@ const Signup = () => {
fullWidth fullWidth
variant="contained" variant="contained"
disabled={username === "" || password === "" || password !== confirm} disabled={username === "" || password === "" || password !== confirm}
sx={{mt: 2, mb: 2}} sx={{ mt: 2, mb: 2 }}
> >
{t("signup_form_button_submit")} {t("signup_form_button_submit")}
</Button> </Button>
{error && {error && (
<Box sx={{ <Box
sx={{
mb: 1, mb: 1,
display: 'flex', display: "flex",
flexGrow: 1, flexGrow: 1,
justifyContent: 'center', justifyContent: "center",
}}> }}
<WarningAmberIcon color="error" sx={{mr: 1}}/> >
<Typography sx={{color: 'error.main'}}>{error}</Typography> <WarningAmberIcon color="error" sx={{ mr: 1 }} />
<Typography sx={{ color: "error.main" }}>{error}</Typography>
</Box> </Box>
} )}
</Box> </Box>
{config.enable_login && {config.enable_login && (
<Typography sx={{mb: 4}}> <Typography sx={{ mb: 4 }}>
<NavLink to={routes.login} variant="body1"> <NavLink to={routes.login} variant="body1">
{t("signup_already_have_account")} {t("signup_already_have_account")}
</NavLink> </NavLink>
</Typography> </Typography>
} )}
</AvatarBox> </AvatarBox>
); );
} };
export default Signup; export default Signup;

View file

@ -1,27 +1,38 @@
import * as React from 'react'; import * as React from "react";
import {useContext, useState} from 'react'; import { useContext, useState } from "react";
import Button from '@mui/material/Button'; import Button from "@mui/material/Button";
import TextField from '@mui/material/TextField'; import TextField from "@mui/material/TextField";
import Dialog from '@mui/material/Dialog'; import Dialog from "@mui/material/Dialog";
import DialogContent from '@mui/material/DialogContent'; import DialogContent from "@mui/material/DialogContent";
import DialogContentText from '@mui/material/DialogContentText'; import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from "@mui/material/DialogTitle";
import {Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery} from "@mui/material"; import {
Autocomplete,
Checkbox,
FormControlLabel,
FormGroup,
useMediaQuery,
} from "@mui/material";
import theme from "./theme"; import theme from "./theme";
import api from "../app/Api"; import api from "../app/Api";
import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils"; import {
randomAlphanumericString,
topicUrl,
validTopic,
validUrl,
} from "../app/utils";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller"; import poller from "../app/Poller";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi, {Permission, Role} from "../app/AccountApi"; import accountApi, { Permission, Role } from "../app/AccountApi";
import ReserveTopicSelect from "./ReserveTopicSelect"; import ReserveTopicSelect from "./ReserveTopicSelect";
import {AccountContext} from "./App"; import { AccountContext } from "./App";
import {TopicReservedError, UnauthorizedError} from "../app/errors"; import { TopicReservedError, UnauthorizedError } from "../app/errors";
import {ReserveLimitChip} from "./SubscriptionPopup"; import { ReserveLimitChip } from "./SubscriptionPopup";
const publicBaseUrl = "https://ntfy.sh"; const publicBaseUrl = "https://ntfy.sh";
@ -29,19 +40,20 @@ const SubscribeDialog = (props) => {
const [baseUrl, setBaseUrl] = useState(""); const [baseUrl, setBaseUrl] = useState("");
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");
const [showLoginPage, setShowLoginPage] = useState(false); const [showLoginPage, setShowLoginPage] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSuccess = async () => { const handleSuccess = async () => {
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
const actualBaseUrl = (baseUrl) ? baseUrl : config.base_url; const actualBaseUrl = baseUrl ? baseUrl : config.base_url;
const subscription = await subscribeTopic(actualBaseUrl, topic); const subscription = await subscribeTopic(actualBaseUrl, topic);
poller.pollInBackground(subscription); // Dangle! poller.pollInBackground(subscription); // Dangle!
props.onSuccess(subscription); props.onSuccess(subscription);
} };
return ( return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}> <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
{!showLoginPage && <SubscribePage {!showLoginPage && (
<SubscribePage
baseUrl={baseUrl} baseUrl={baseUrl}
setBaseUrl={setBaseUrl} setBaseUrl={setBaseUrl}
topic={topic} topic={topic}
@ -50,13 +62,16 @@ const SubscribeDialog = (props) => {
onCancel={props.onCancel} onCancel={props.onCancel}
onNeedsLogin={() => setShowLoginPage(true)} onNeedsLogin={() => setShowLoginPage(true)}
onSuccess={handleSuccess} onSuccess={handleSuccess}
/>} />
{showLoginPage && <LoginPage )}
{showLoginPage && (
<LoginPage
baseUrl={baseUrl} baseUrl={baseUrl}
topic={topic} topic={topic}
onBack={() => setShowLoginPage(false)} onBack={() => setShowLoginPage(false)}
onSuccess={handleSuccess} onSuccess={handleSuccess}
/>} />
)}
</Dialog> </Dialog>
); );
}; };
@ -68,25 +83,45 @@ const SubscribePage = (props) => {
const [reserveTopicVisible, setReserveTopicVisible] = useState(false); const [reserveTopicVisible, setReserveTopicVisible] = useState(false);
const [anotherServerVisible, setAnotherServerVisible] = useState(false); const [anotherServerVisible, setAnotherServerVisible] = useState(false);
const [everyone, setEveryone] = useState(Permission.DENY_ALL); const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const baseUrl = (anotherServerVisible) ? props.baseUrl : config.base_url; const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url;
const topic = props.topic; const topic = props.topic;
const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic)); const existingTopicUrls = props.subscriptions.map((s) =>
const existingBaseUrls = Array topicUrl(s.baseUrl, s.topic)
.from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)])) );
.filter(s => s !== config.base_url); const existingBaseUrls = Array.from(
const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account); new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])
const reserveTopicEnabled = session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0)); ).filter((s) => s !== config.base_url);
const showReserveTopicCheckbox =
config.enable_reservations &&
!anotherServerVisible &&
(config.enable_payments || account);
const reserveTopicEnabled =
session.exists() &&
(account?.role === Role.ADMIN ||
(account?.role === Role.USER &&
(account?.stats.reservations_remaining || 0) > 0));
const handleSubscribe = async () => { const handleSubscribe = async () => {
const user = await userManager.get(baseUrl); // May be undefined const user = await userManager.get(baseUrl); // May be undefined
const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous"); const username = user
? user.username
: t("subscribe_dialog_error_user_anonymous");
// Check read access to topic // Check read access to topic
const success = await api.topicAuth(baseUrl, topic, user); const success = await api.topicAuth(baseUrl, topic, user);
if (!success) { if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); console.log(
`[SubscribeDialog] Login to ${topicUrl(
baseUrl,
topic
)} failed for user ${username}`
);
if (user) { if (user) {
setError(t("subscribe_dialog_error_user_not_authorized", { username: username })); setError(
t("subscribe_dialog_error_user_not_authorized", {
username: username,
})
);
return; return;
} else { } else {
props.onNeedsLogin(); props.onNeedsLogin();
@ -95,8 +130,14 @@ const SubscribePage = (props) => {
} }
// Reserve topic (if requested) // Reserve topic (if requested)
if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) { if (
console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`); session.exists() &&
baseUrl === config.base_url &&
reserveTopicVisible
) {
console.log(
`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`
);
try { try {
await accountApi.upsertReservation(topic, everyone); await accountApi.upsertReservation(topic, everyone);
} catch (e) { } catch (e) {
@ -110,7 +151,12 @@ const SubscribePage = (props) => {
} }
} }
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); console.log(
`[SubscribeDialog] Successful login to ${topicUrl(
baseUrl,
topic
)} for user ${username}`
);
props.onSuccess(); props.onSuccess();
}; };
@ -121,17 +167,21 @@ const SubscribePage = (props) => {
const subscribeButtonEnabled = (() => { const subscribeButtonEnabled = (() => {
if (anotherServerVisible) { if (anotherServerVisible) {
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic)); const isExistingTopicUrl = existingTopicUrls.includes(
topicUrl(baseUrl, topic)
);
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
} else { } else {
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic)); const isExistingTopicUrl = existingTopicUrls.includes(
topicUrl(config.base_url, topic)
);
return validTopic(topic) && !isExistingTopicUrl; return validTopic(topic) && !isExistingTopicUrl;
} }
})(); })();
const updateBaseUrl = (ev, newVal) => { const updateBaseUrl = (ev, newVal) => {
if (validUrl(newVal)) { if (validUrl(newVal)) {
props.setBaseUrl(newVal.replace(/\/$/, '')); // strip trailing slash after https?:// props.setBaseUrl(newVal.replace(/\/$/, "")); // strip trailing slash after https?://
} else { } else {
props.setBaseUrl(newVal); props.setBaseUrl(newVal);
} }
@ -144,27 +194,32 @@ const SubscribePage = (props) => {
<DialogContentText> <DialogContentText>
{t("subscribe_dialog_subscribe_description")} {t("subscribe_dialog_subscribe_description")}
</DialogContentText> </DialogContentText>
<div style={{display: 'flex', paddingBottom: "8px"}} role="row"> <div style={{ display: "flex", paddingBottom: "8px" }} role="row">
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"
id="topic" id="topic"
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")} placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
value={props.topic} value={props.topic}
onChange={ev => props.setTopic(ev.target.value)} onChange={(ev) => props.setTopic(ev.target.value)}
type="text" type="text"
fullWidth fullWidth
variant="standard" variant="standard"
inputProps={{ inputProps={{
maxLength: 64, maxLength: 64,
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder") "aria-label": t("subscribe_dialog_subscribe_topic_placeholder"),
}} }}
/> />
<Button onClick={() => {props.setTopic(randomAlphanumericString(16))}} style={{flexShrink: "0", marginTop: "0.5em"}}> <Button
onClick={() => {
props.setTopic(randomAlphanumericString(16));
}}
style={{ flexShrink: "0", marginTop: "0.5em" }}
>
{t("subscribe_dialog_subscribe_button_generate_topic_name")} {t("subscribe_dialog_subscribe_button_generate_topic_name")}
</Button> </Button>
</div> </div>
{showReserveTopicCheckbox && {showReserveTopicCheckbox && (
<FormGroup> <FormGroup>
<FormControlLabel <FormControlLabel
variant="standard" variant="standard"
@ -175,57 +230,63 @@ const SubscribePage = (props) => {
checked={reserveTopicVisible} checked={reserveTopicVisible}
onChange={(ev) => setReserveTopicVisible(ev.target.checked)} onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
inputProps={{ inputProps={{
"aria-label": t("reserve_dialog_checkbox_label") "aria-label": t("reserve_dialog_checkbox_label"),
}} }}
/> />
} }
label={ label={
<> <>
{t("reserve_dialog_checkbox_label")} {t("reserve_dialog_checkbox_label")}
<ReserveLimitChip/> <ReserveLimitChip />
</> </>
} }
/> />
{reserveTopicVisible && {reserveTopicVisible && (
<ReserveTopicSelect <ReserveTopicSelect value={everyone} onChange={setEveryone} />
value={everyone} )}
onChange={setEveryone}
/>
}
</FormGroup> </FormGroup>
} )}
{!reserveTopicVisible && {!reserveTopicVisible && (
<FormGroup> <FormGroup>
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox <Checkbox
onChange={handleUseAnotherChanged} onChange={handleUseAnotherChanged}
inputProps={{ inputProps={{
"aria-label": t("subscribe_dialog_subscribe_use_another_label") "aria-label": t(
"subscribe_dialog_subscribe_use_another_label"
),
}} }}
/> />
} }
label={t("subscribe_dialog_subscribe_use_another_label")}/> label={t("subscribe_dialog_subscribe_use_another_label")}
{anotherServerVisible && <Autocomplete />
{anotherServerVisible && (
<Autocomplete
freeSolo freeSolo
options={existingBaseUrls} options={existingBaseUrls}
inputValue={props.baseUrl} inputValue={props.baseUrl}
onInputChange={updateBaseUrl} onInputChange={updateBaseUrl}
renderInput={(params) => renderInput={(params) => (
<TextField <TextField
{...params} {...params}
placeholder={config.base_url} placeholder={config.base_url}
variant="standard" variant="standard"
aria-label={t("subscribe_dialog_subscribe_base_url_label")} aria-label={t("subscribe_dialog_subscribe_base_url_label")}
/> />
} )}
/>} />
)}
</FormGroup> </FormGroup>
} )}
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button> <Button onClick={props.onCancel}>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>{t("subscribe_dialog_subscribe_button_subscribe")}</Button> {t("subscribe_dialog_subscribe_button_cancel")}
</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>
{t("subscribe_dialog_subscribe_button_subscribe")}
</Button>
</DialogFooter> </DialogFooter>
</> </>
); );
@ -236,18 +297,30 @@ const LoginPage = (props) => {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const baseUrl = (props.baseUrl) ? props.baseUrl : config.base_url; const baseUrl = props.baseUrl ? props.baseUrl : config.base_url;
const topic = props.topic; const topic = props.topic;
const handleLogin = async () => { const handleLogin = async () => {
const user = {baseUrl, username, password}; const user = { baseUrl, username, password };
const success = await api.topicAuth(baseUrl, topic, user); const success = await api.topicAuth(baseUrl, topic, user);
if (!success) { if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); console.log(
setError(t("subscribe_dialog_error_user_not_authorized", { username: username })); `[SubscribeDialog] Login to ${topicUrl(
baseUrl,
topic
)} failed for user ${username}`
);
setError(
t("subscribe_dialog_error_user_not_authorized", { username: username })
);
return; return;
} }
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); console.log(
`[SubscribeDialog] Successful login to ${topicUrl(
baseUrl,
topic
)} for user ${username}`
);
await userManager.save(user); await userManager.save(user);
props.onSuccess(); props.onSuccess();
}; };
@ -265,12 +338,12 @@ const LoginPage = (props) => {
id="username" id="username"
label={t("subscribe_dialog_login_username_label")} label={t("subscribe_dialog_login_username_label")}
value={username} value={username}
onChange={ev => setUsername(ev.target.value)} onChange={(ev) => setUsername(ev.target.value)}
type="text" type="text"
fullWidth fullWidth
variant="standard" variant="standard"
inputProps={{ inputProps={{
"aria-label": t("subscribe_dialog_login_username_label") "aria-label": t("subscribe_dialog_login_username_label"),
}} }}
/> />
<TextField <TextField
@ -279,17 +352,19 @@ const LoginPage = (props) => {
label={t("subscribe_dialog_login_password_label")} label={t("subscribe_dialog_login_password_label")}
type="password" type="password"
value={password} value={password}
onChange={ev => setPassword(ev.target.value)} onChange={(ev) => setPassword(ev.target.value)}
fullWidth fullWidth
variant="standard" variant="standard"
inputProps={{ inputProps={{
"aria-label": t("subscribe_dialog_login_password_label") "aria-label": t("subscribe_dialog_login_password_label"),
}} }}
/> />
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onBack}>{t("common_back")}</Button> <Button onClick={props.onBack}>{t("common_back")}</Button>
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button> <Button onClick={handleLogin}>
{t("subscribe_dialog_login_button_login")}
</Button>
</DialogFooter> </DialogFooter>
</> </>
); );

View file

@ -1,29 +1,39 @@
import * as React from 'react'; import * as React from "react";
import {useContext, useState} from 'react'; import { useContext, useState } from "react";
import Button from '@mui/material/Button'; import Button from "@mui/material/Button";
import TextField from '@mui/material/TextField'; import TextField from "@mui/material/TextField";
import Dialog from '@mui/material/Dialog'; import Dialog from "@mui/material/Dialog";
import DialogContent from '@mui/material/DialogContent'; import DialogContent from "@mui/material/DialogContent";
import DialogContentText from '@mui/material/DialogContentText'; import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from "@mui/material/DialogTitle";
import {Chip, InputAdornment, Portal, Snackbar, useMediaQuery} from "@mui/material"; import {
Chip,
InputAdornment,
Portal,
Snackbar,
useMediaQuery,
} from "@mui/material";
import theme from "./theme"; import theme from "./theme";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import accountApi, {Role} from "../app/AccountApi"; import accountApi, { Role } from "../app/AccountApi";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import PopupMenu from "./PopupMenu"; import PopupMenu from "./PopupMenu";
import {formatShortDateTime, shuffle} from "../app/utils"; import { formatShortDateTime, shuffle } from "../app/utils";
import api from "../app/Api"; import api from "../app/Api";
import {useNavigate} from "react-router-dom"; import { useNavigate } from "react-router-dom";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import {Clear} from "@mui/icons-material"; import { Clear } from "@mui/icons-material";
import {AccountContext} from "./App"; import { AccountContext } from "./App";
import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs"; import {
import {UnauthorizedError} from "../app/errors"; ReserveAddDialog,
ReserveDeleteDialog,
ReserveEditDialog,
} from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors";
export const SubscriptionPopup = (props) => { export const SubscriptionPopup = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -38,34 +48,58 @@ export const SubscriptionPopup = (props) => {
const placement = props.placement ?? "left"; const placement = props.placement ?? "left";
const reservations = account?.reservations || []; const reservations = account?.reservations || [];
const showReservationAdd = config.enable_reservations && !subscription?.reservation && account?.stats.reservations_remaining > 0; const showReservationAdd =
const showReservationAddDisabled = !showReservationAdd && config.enable_reservations && !subscription?.reservation && (config.enable_payments || account?.stats.reservations_remaining === 0); config.enable_reservations &&
const showReservationEdit = config.enable_reservations && !!subscription?.reservation; !subscription?.reservation &&
const showReservationDelete = config.enable_reservations && !!subscription?.reservation; account?.stats.reservations_remaining > 0;
const showReservationAddDisabled =
!showReservationAdd &&
config.enable_reservations &&
!subscription?.reservation &&
(config.enable_payments || account?.stats.reservations_remaining === 0);
const showReservationEdit =
config.enable_reservations && !!subscription?.reservation;
const showReservationDelete =
config.enable_reservations && !!subscription?.reservation;
const handleChangeDisplayName = async () => { const handleChangeDisplayName = async () => {
setDisplayNameDialogOpen(true); setDisplayNameDialogOpen(true);
} };
const handleReserveAdd = async () => { const handleReserveAdd = async () => {
setReserveAddDialogOpen(true); setReserveAddDialogOpen(true);
} };
const handleReserveEdit = async () => { const handleReserveEdit = async () => {
setReserveEditDialogOpen(true); setReserveEditDialogOpen(true);
} };
const handleReserveDelete = async () => { const handleReserveDelete = async () => {
setReserveDeleteDialogOpen(true); setReserveDeleteDialogOpen(true);
} };
const handleSendTestMessage = async () => { const handleSendTestMessage = async () => {
const baseUrl = props.subscription.baseUrl; const baseUrl = props.subscription.baseUrl;
const topic = props.subscription.topic; const topic = props.subscription.topic;
const tags = shuffle([ const tags = shuffle([
"grinning", "octopus", "upside_down_face", "palm_tree", "maple_leaf", "apple", "skull", "warning", "jack_o_lantern", "grinning",
"de-server-1", "backups", "cron-script", "script-error", "phils-automation", "mouse", "go-rocks", "hi-ben"]) "octopus",
.slice(0, Math.round(Math.random() * 4)); "upside_down_face",
"palm_tree",
"maple_leaf",
"apple",
"skull",
"warning",
"jack_o_lantern",
"de-server-1",
"backups",
"cron-script",
"script-error",
"phils-automation",
"mouse",
"go-rocks",
"hi-ben",
]).slice(0, Math.round(Math.random() * 4));
const priority = shuffle([1, 2, 3, 4, 5])[0]; const priority = shuffle([1, 2, 3, 4, 5])[0];
const title = shuffle([ const title = shuffle([
"", "",
@ -77,41 +111,53 @@ export const SubscriptionPopup = (props) => {
"I don't really like apples", "I don't really like apples",
"My favorite TV show is The Wire. You should watch it!", "My favorite TV show is The Wire. You should watch it!",
"You can attach files and URLs to messages too", "You can attach files and URLs to messages too",
"You can delay messages up to 3 days" "You can delay messages up to 3 days",
])[0]; ])[0];
const nowSeconds = Math.round(Date.now()/1000); const nowSeconds = Math.round(Date.now() / 1000);
const message = shuffle([ const message = shuffle([
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`, `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(
nowSeconds
)} right now. Is that early or late?`,
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`, `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`, `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
`Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`, `Alright then, it's ${formatShortDateTime(
nowSeconds
)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`, `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`, `I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?` `It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`,
])[0]; ])[0];
try { try {
await api.publish(baseUrl, topic, message, { await api.publish(baseUrl, topic, message, {
title: title, title: title,
priority: priority, priority: priority,
tags: tags tags: tags,
}); });
} catch (e) { } catch (e) {
console.log(`[SubscriptionPopup] Error publishing message`, e); console.log(`[SubscriptionPopup] Error publishing message`, e);
setShowPublishError(true); setShowPublishError(true);
} }
} };
const handleClearAll = async () => { const handleClearAll = async () => {
console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`); console.log(
`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`
);
await subscriptionManager.deleteNotifications(props.subscription.id); await subscriptionManager.deleteNotifications(props.subscription.id);
}; };
const handleUnsubscribe = async () => { const handleUnsubscribe = async () => {
console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription); console.log(
`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`,
props.subscription
);
await subscriptionManager.remove(props.subscription.id); await subscriptionManager.remove(props.subscription.id);
if (session.exists() && !subscription.internal) { if (session.exists() && !subscription.internal) {
try { try {
await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic); await accountApi.deleteSubscription(
props.subscription.baseUrl,
props.subscription.topic
);
} catch (e) { } catch (e) {
console.log(`[SubscriptionPopup] Error unsubscribing`, e); console.log(`[SubscriptionPopup] Error unsubscribing`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
@ -135,19 +181,41 @@ export const SubscriptionPopup = (props) => {
open={!!props.anchor} open={!!props.anchor}
onClose={props.onClose} onClose={props.onClose}
> >
<MenuItem onClick={handleChangeDisplayName}>{t("action_bar_change_display_name")}</MenuItem> <MenuItem onClick={handleChangeDisplayName}>
{showReservationAdd && <MenuItem onClick={handleReserveAdd}>{t("action_bar_reservation_add")}</MenuItem>} {t("action_bar_change_display_name")}
{showReservationAddDisabled && </MenuItem>
<MenuItem sx={{ cursor: "default" }}> {showReservationAdd && (
<span style={{ opacity: 0.3 }}>{t("action_bar_reservation_add")}</span> <MenuItem onClick={handleReserveAdd}>
<ReserveLimitChip/> {t("action_bar_reservation_add")}
</MenuItem>
)}
{showReservationAddDisabled && (
<MenuItem sx={{ cursor: "default" }}>
<span style={{ opacity: 0.3 }}>
{t("action_bar_reservation_add")}
</span>
<ReserveLimitChip />
</MenuItem>
)}
{showReservationEdit && (
<MenuItem onClick={handleReserveEdit}>
{t("action_bar_reservation_edit")}
</MenuItem>
)}
{showReservationDelete && (
<MenuItem onClick={handleReserveDelete}>
{t("action_bar_reservation_delete")}
</MenuItem>
)}
<MenuItem onClick={handleSendTestMessage}>
{t("action_bar_send_test_notification")}
</MenuItem>
<MenuItem onClick={handleClearAll}>
{t("action_bar_clear_notifications")}
</MenuItem>
<MenuItem onClick={handleUnsubscribe}>
{t("action_bar_unsubscribe")}
</MenuItem> </MenuItem>
}
{showReservationEdit && <MenuItem onClick={handleReserveEdit}>{t("action_bar_reservation_edit")}</MenuItem>}
{showReservationDelete && <MenuItem onClick={handleReserveDelete}>{t("action_bar_reservation_delete")}</MenuItem>}
<MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
<MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
<MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
</PopupMenu> </PopupMenu>
<Portal> <Portal>
<Snackbar <Snackbar
@ -161,29 +229,29 @@ export const SubscriptionPopup = (props) => {
subscription={subscription} subscription={subscription}
onClose={() => setDisplayNameDialogOpen(false)} onClose={() => setDisplayNameDialogOpen(false)}
/> />
{showReservationAdd && {showReservationAdd && (
<ReserveAddDialog <ReserveAddDialog
open={reserveAddDialogOpen} open={reserveAddDialogOpen}
topic={subscription.topic} topic={subscription.topic}
reservations={reservations} reservations={reservations}
onClose={() => setReserveAddDialogOpen(false)} onClose={() => setReserveAddDialogOpen(false)}
/> />
} )}
{showReservationEdit && {showReservationEdit && (
<ReserveEditDialog <ReserveEditDialog
open={reserveEditDialogOpen} open={reserveEditDialogOpen}
reservation={subscription.reservation} reservation={subscription.reservation}
reservations={props.reservations} reservations={props.reservations}
onClose={() => setReserveEditDialogOpen(false)} onClose={() => setReserveEditDialogOpen(false)}
/> />
} )}
{showReservationDelete && {showReservationDelete && (
<ReserveDeleteDialog <ReserveDeleteDialog
open={reserveDeleteDialogOpen} open={reserveDeleteDialogOpen}
topic={subscription.topic} topic={subscription.topic}
onClose={() => setReserveDeleteDialogOpen(false)} onClose={() => setReserveDeleteDialogOpen(false)}
/> />
} )}
</Portal> </Portal>
</> </>
); );
@ -193,17 +261,28 @@ const DisplayNameDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const subscription = props.subscription; const subscription = props.subscription;
const [error, setError] = useState(""); const [error, setError] = useState("");
const [displayName, setDisplayName] = useState(subscription.displayName ?? ""); const [displayName, setDisplayName] = useState(
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); subscription.displayName ?? ""
);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSave = async () => { const handleSave = async () => {
await subscriptionManager.setDisplayName(subscription.id, displayName); await subscriptionManager.setDisplayName(subscription.id, displayName);
if (session.exists() && !subscription.internal) { if (session.exists() && !subscription.internal) {
try { try {
console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`); console.log(
await accountApi.updateSubscription(subscription.baseUrl, subscription.topic, { display_name: displayName }); `[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`
);
await accountApi.updateSubscription(
subscription.baseUrl,
subscription.topic,
{ display_name: displayName }
);
} catch (e) { } catch (e) {
console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e); console.log(
`[SubscriptionSettingsDialog] Error updating subscription`,
e
);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else { } else {
@ -213,10 +292,16 @@ const DisplayNameDialog = (props) => {
} }
} }
props.onClose(); props.onClose();
} };
return ( return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <Dialog
open={props.open}
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>{t("display_name_dialog_title")}</DialogTitle> <DialogTitle>{t("display_name_dialog_title")}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
@ -226,22 +311,22 @@ const DisplayNameDialog = (props) => {
autoFocus autoFocus
placeholder={t("display_name_dialog_placeholder")} placeholder={t("display_name_dialog_placeholder")}
value={displayName} value={displayName}
onChange={ev => setDisplayName(ev.target.value)} onChange={(ev) => setDisplayName(ev.target.value)}
type="text" type="text"
fullWidth fullWidth
variant="standard" variant="standard"
inputProps={{ inputProps={{
maxLength: 64, maxLength: 64,
"aria-label": t("display_name_dialog_placeholder") "aria-label": t("display_name_dialog_placeholder"),
}} }}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<IconButton onClick={() => setDisplayName("")} edge="end"> <IconButton onClick={() => setDisplayName("")} edge="end">
<Clear/> <Clear />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
) ),
}} }}
/> />
</DialogContent> </DialogContent>
@ -255,12 +340,19 @@ const DisplayNameDialog = (props) => {
export const ReserveLimitChip = () => { export const ReserveLimitChip = () => {
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) { if (
account?.role === Role.ADMIN ||
account?.stats.reservations_remaining > 0
) {
return <></>; return <></>;
} else if (config.enable_payments) { } else if (config.enable_payments) {
return (account?.limits.reservations > 0) ? <LimitReachedChip/> : <ProChip/>; return account?.limits.reservations > 0 ? (
<LimitReachedChip />
) : (
<ProChip />
);
} else if (account) { } else if (account) {
return <LimitReachedChip/>; return <LimitReachedChip />;
} }
return <></>; return <></>;
}; };
@ -272,7 +364,12 @@ const LimitReachedChip = () => {
label={t("action_bar_reservation_limit_reached")} label={t("action_bar_reservation_limit_reached")}
variant="outlined" variant="outlined"
color="primary" color="primary"
sx={{ opacity: 0.8, borderWidth: "2px", height: "24px", marginLeft: "5px" }} sx={{
opacity: 0.8,
borderWidth: "2px",
height: "24px",
marginLeft: "5px",
}}
/> />
); );
}; };
@ -284,9 +381,13 @@ export const ProChip = () => {
label={"ntfy Pro"} label={"ntfy Pro"}
variant="outlined" variant="outlined"
color="primary" color="primary"
sx={{ opacity: 0.8, fontWeight: "bold", borderWidth: "2px", height: "24px", marginLeft: "5px" }} sx={{
opacity: 0.8,
fontWeight: "bold",
borderWidth: "2px",
height: "24px",
marginLeft: "5px",
}}
/> />
); );
}; };

View file

@ -1,26 +1,40 @@
import * as React from 'react'; import * as React from "react";
import {useContext, useEffect, useState} from 'react'; import { useContext, useEffect, useState } from "react";
import Dialog from '@mui/material/Dialog'; import Dialog from "@mui/material/Dialog";
import DialogContent from '@mui/material/DialogContent'; import DialogContent from "@mui/material/DialogContent";
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from "@mui/material/DialogTitle";
import {Alert, CardActionArea, CardContent, Chip, Link, ListItem, Switch, useMediaQuery} from "@mui/material"; import {
Alert,
CardActionArea,
CardContent,
Chip,
Link,
ListItem,
Switch,
useMediaQuery,
} from "@mui/material";
import theme from "./theme"; import theme from "./theme";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import accountApi, {SubscriptionInterval} from "../app/AccountApi"; import accountApi, { SubscriptionInterval } from "../app/AccountApi";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import {AccountContext} from "./App"; import { AccountContext } from "./App";
import {formatBytes, formatNumber, formatPrice, formatShortDate} from "../app/utils"; import {
import {Trans, useTranslation} from "react-i18next"; formatBytes,
formatNumber,
formatPrice,
formatShortDate,
} from "../app/utils";
import { Trans, useTranslation } from "react-i18next";
import List from "@mui/material/List"; import List from "@mui/material/List";
import {Check, Close} from "@mui/icons-material"; import { Check, Close } from "@mui/icons-material";
import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText"; import ListItemText from "@mui/material/ListItemText";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import {NavLink} from "react-router-dom"; import { NavLink } from "react-router-dom";
import {UnauthorizedError} from "../app/errors"; import { UnauthorizedError } from "../app/errors";
import DialogContentText from "@mui/material/DialogContentText"; import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions"; import DialogActions from "@mui/material/DialogActions";
@ -29,15 +43,17 @@ const UpgradeDialog = (props) => {
const { account } = useContext(AccountContext); // May be undefined! const { account } = useContext(AccountContext); // May be undefined!
const [error, setError] = useState(""); const [error, setError] = useState("");
const [tiers, setTiers] = useState(null); const [tiers, setTiers] = useState(null);
const [interval, setInterval] = useState(account?.billing?.interval || SubscriptionInterval.YEAR); const [interval, setInterval] = useState(
account?.billing?.interval || SubscriptionInterval.YEAR
);
const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
useEffect(() => { useEffect(() => {
const fetchTiers = async () => { const fetchTiers = async () => {
setTiers(await accountApi.billingTiers()); setTiers(await accountApi.billingTiers());
} };
fetchTiers(); // Dangle fetchTiers(); // Dangle
}, []); }, []);
@ -45,7 +61,9 @@ const UpgradeDialog = (props) => {
return <></>; return <></>;
} }
const tiersMap = Object.assign(...tiers.map(tier => ({[tier.code]: tier}))); const tiersMap = Object.assign(
...tiers.map((tier) => ({ [tier.code]: tier }))
);
const newTier = tiersMap[newTierCode]; // May be undefined const newTier = tiersMap[newTierCode]; // May be undefined
const currentTier = account?.tier; // May be undefined const currentTier = account?.tier; // May be undefined
const currentInterval = account?.billing?.interval; // May be undefined const currentInterval = account?.billing?.interval; // May be undefined
@ -57,10 +75,13 @@ const UpgradeDialog = (props) => {
submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup"); submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
submitAction = Action.REDIRECT_SIGNUP; submitAction = Action.REDIRECT_SIGNUP;
banner = null; banner = null;
} else if (currentTierCode === newTierCode && (currentInterval === undefined || currentInterval === interval)) { } else if (
currentTierCode === newTierCode &&
(currentInterval === undefined || currentInterval === interval)
) {
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
submitAction = null; submitAction = null;
banner = (currentTierCode) ? Banner.PRORATION_INFO : null; banner = currentTierCode ? Banner.PRORATION_INFO : null;
} else if (!currentTierCode) { } else if (!currentTierCode) {
submitButtonLabel = t("account_upgrade_dialog_button_pay_now"); submitButtonLabel = t("account_upgrade_dialog_button_pay_now");
submitAction = Action.CREATE_SUBSCRIPTION; submitAction = Action.CREATE_SUBSCRIPTION;
@ -78,7 +99,10 @@ const UpgradeDialog = (props) => {
// Exceptional conditions // Exceptional conditions
if (loading) { if (loading) {
submitAction = null; submitAction = null;
} else if (newTier?.code && account?.reservations?.length > newTier?.limits?.reservations) { } else if (
newTier?.code &&
account?.reservations?.length > newTier?.limits?.reservations
) {
submitAction = null; submitAction = null;
banner = Banner.RESERVATIONS_WARNING; banner = Banner.RESERVATIONS_WARNING;
} }
@ -91,7 +115,10 @@ const UpgradeDialog = (props) => {
try { try {
setLoading(true); setLoading(true);
if (submitAction === Action.CREATE_SUBSCRIPTION) { if (submitAction === Action.CREATE_SUBSCRIPTION) {
const response = await accountApi.createBillingSubscription(newTierCode, interval); const response = await accountApi.createBillingSubscription(
newTierCode,
interval
);
window.location.href = response.redirect_url; window.location.href = response.redirect_url;
} else if (submitAction === Action.UPDATE_SUBSCRIPTION) { } else if (submitAction === Action.UPDATE_SUBSCRIPTION) {
await accountApi.updateBillingSubscription(newTierCode, interval); await accountApi.updateBillingSubscription(newTierCode, interval);
@ -109,17 +136,22 @@ const UpgradeDialog = (props) => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
} };
// Figure out discount // Figure out discount
let discount = 0, upto = false; let discount = 0,
upto = false;
if (newTier?.prices) { if (newTier?.prices) {
discount = Math.round(((newTier.prices.month*12/newTier.prices.year)-1)*100); discount = Math.round(
((newTier.prices.month * 12) / newTier.prices.year - 1) * 100
);
} else { } else {
let n = 0; let n = 0;
for (const t of tiers) { for (const t of tiers) {
if (t.prices) { if (t.prices) {
const tierDiscount = Math.round(((t.prices.month*12/t.prices.year)-1)*100); const tierDiscount = Math.round(
((t.prices.month * 12) / t.prices.year - 1) * 100
);
if (tierDiscount > discount) { if (tierDiscount > discount) {
discount = tierDiscount; discount = tierDiscount;
n++; n++;
@ -139,99 +171,150 @@ const UpgradeDialog = (props) => {
<DialogTitle> <DialogTitle>
<div style={{ display: "flex", flexDirection: "row" }}> <div style={{ display: "flex", flexDirection: "row" }}>
<div style={{ flexGrow: 1 }}>{t("account_upgrade_dialog_title")}</div> <div style={{ flexGrow: 1 }}>{t("account_upgrade_dialog_title")}</div>
<div style={{ <div
style={{
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
marginTop: "4px" marginTop: "4px",
}}> }}
<Typography component="span" variant="subtitle1">{t("account_upgrade_dialog_interval_monthly")}</Typography> >
<Typography component="span" variant="subtitle1">
{t("account_upgrade_dialog_interval_monthly")}
</Typography>
<Switch <Switch
checked={interval === SubscriptionInterval.YEAR} checked={interval === SubscriptionInterval.YEAR}
onChange={(ev) => setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)} onChange={(ev) =>
setInterval(
ev.target.checked
? SubscriptionInterval.YEAR
: SubscriptionInterval.MONTH
)
}
/> />
<Typography component="span" variant="subtitle1">{t("account_upgrade_dialog_interval_yearly")}</Typography> <Typography component="span" variant="subtitle1">
{discount > 0 && {t("account_upgrade_dialog_interval_yearly")}
</Typography>
{discount > 0 && (
<Chip <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 })} label={
upto
? t(
"account_upgrade_dialog_interval_yearly_discount_save_up_to",
{ discount: discount }
)
: t(
"account_upgrade_dialog_interval_yearly_discount_save",
{ discount: discount }
)
}
color="primary" color="primary"
size="small" size="small"
variant={interval === SubscriptionInterval.YEAR ? "filled" : "outlined"} variant={
interval === SubscriptionInterval.YEAR ? "filled" : "outlined"
}
sx={{ marginLeft: "5px" }} sx={{ marginLeft: "5px" }}
/> />
} )}
</div> </div>
</div> </div>
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<div style={{ <div
style={{
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
marginBottom: "8px", marginBottom: "8px",
width: "100%" width: "100%",
}}> }}
{tiers.map(tier => >
{tiers.map((tier) => (
<TierCard <TierCard
key={`tierCard${tier.code || '_free'}`} key={`tierCard${tier.code || "_free"}`}
tier={tier} tier={tier}
current={currentTierCode === tier.code} // tier.code or currentTierCode may be undefined! current={currentTierCode === tier.code} // tier.code or currentTierCode may be undefined!
selected={newTierCode === tier.code} // tier.code may be undefined! selected={newTierCode === tier.code} // tier.code may be undefined!
interval={interval} interval={interval}
onClick={() => setNewTierCode(tier.code)} // tier.code may be undefined! onClick={() => setNewTierCode(tier.code)} // tier.code may be undefined!
/> />
)} ))}
</div> </div>
{banner === Banner.CANCEL_WARNING && {banner === Banner.CANCEL_WARNING && (
<Alert severity="warning" sx={{ fontSize: "1rem" }}> <Alert severity="warning" sx={{ fontSize: "1rem" }}>
<Trans <Trans
i18nKey="account_upgrade_dialog_cancel_warning" i18nKey="account_upgrade_dialog_cancel_warning"
values={{ date: formatShortDate(account?.billing?.paid_until || 0) }} /> values={{
</Alert> date: formatShortDate(account?.billing?.paid_until || 0),
}
{banner === Banner.PRORATION_INFO &&
<Alert severity="info" sx={{ fontSize: "1rem" }}>
<Trans i18nKey="account_upgrade_dialog_proration_info" />
</Alert>
}
{banner === Banner.RESERVATIONS_WARNING &&
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
<Trans
i18nKey="account_upgrade_dialog_reservations_warning"
count={account?.reservations.length - newTier?.limits.reservations}
components={{
Link: <NavLink to={routes.settings}/>,
}} }}
/> />
</Alert> </Alert>
)}
{banner === Banner.PRORATION_INFO && (
<Alert severity="info" sx={{ fontSize: "1rem" }}>
<Trans i18nKey="account_upgrade_dialog_proration_info" />
</Alert>
)}
{banner === Banner.RESERVATIONS_WARNING && (
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
<Trans
i18nKey="account_upgrade_dialog_reservations_warning"
count={
account?.reservations.length - newTier?.limits.reservations
} }
components={{
Link: <NavLink to={routes.settings} />,
}}
/>
</Alert>
)}
</DialogContent> </DialogContent>
<Box sx={{ <Box
display: 'flex', sx={{
flexDirection: 'row', display: "flex",
justifyContent: 'space-between', flexDirection: "row",
paddingLeft: '24px', justifyContent: "space-between",
paddingBottom: '8px', paddingLeft: "24px",
}}> paddingBottom: "8px",
}}
>
<DialogContentText <DialogContentText
component="div" component="div"
aria-live="polite" aria-live="polite"
sx={{ sx={{
margin: '0px', margin: "0px",
paddingTop: '12px', paddingTop: "12px",
paddingBottom: '4px' paddingBottom: "4px",
}} }}
> >
{config.billing_contact.indexOf('@') !== -1 && {config.billing_contact.indexOf("@") !== -1 && (
<><Trans i18nKey="account_upgrade_dialog_billing_contact_email" components={{ Link: <Link href={`mailto:${config.billing_contact}`}/> }}/>{" "}</> <>
} <Trans
{config.billing_contact.match(`^http?s://`) && i18nKey="account_upgrade_dialog_billing_contact_email"
<><Trans i18nKey="account_upgrade_dialog_billing_contact_website" components={{ Link: <Link href={config.billing_contact} target="_blank"/> }}/>{" "}</> components={{
} Link: <Link href={`mailto:${config.billing_contact}`} />,
}}
/>{" "}
</>
)}
{config.billing_contact.match(`^http?s://`) && (
<>
<Trans
i18nKey="account_upgrade_dialog_billing_contact_website"
components={{
Link: <Link href={config.billing_contact} target="_blank" />,
}}
/>{" "}
</>
)}
{error} {error}
</DialogContentText> </DialogContentText>
<DialogActions sx={{paddingRight: 2}}> <DialogActions sx={{ paddingRight: 2 }}>
<Button onClick={props.onCancel}>{t("account_upgrade_dialog_button_cancel")}</Button> <Button onClick={props.onCancel}>
<Button onClick={handleSubmit} disabled={!submitAction}>{submitButtonLabel}</Button> {t("account_upgrade_dialog_button_cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!submitAction}>
{submitButtonLabel}
</Button>
</DialogActions> </DialogActions>
</Box> </Box>
</Dialog> </Dialog>
@ -259,13 +342,14 @@ const TierCard = (props) => {
if (!tier.prices) { if (!tier.prices) {
monthlyPrice = 0; monthlyPrice = 0;
} else if (props.interval === SubscriptionInterval.YEAR) { } else if (props.interval === SubscriptionInterval.YEAR) {
monthlyPrice = tier.prices.year/12; monthlyPrice = tier.prices.year / 12;
} else if (props.interval === SubscriptionInterval.MONTH) { } else if (props.interval === SubscriptionInterval.MONTH) {
monthlyPrice = tier.prices.month; monthlyPrice = tier.prices.month;
} }
return ( return (
<Box sx={{ <Box
sx={{
m: "7px", m: "7px",
minWidth: "240px", minWidth: "240px",
flexGrow: 1, flexGrow: 1,
@ -274,80 +358,129 @@ const TierCard = (props) => {
borderRadius: "5px", borderRadius: "5px",
"&:first-of-type": { ml: 0 }, "&:first-of-type": { ml: 0 },
"&:last-of-type": { mr: 0 }, "&:last-of-type": { mr: 0 },
...cardStyle ...cardStyle,
}}> }}
>
<Card sx={{ height: "100%" }}> <Card sx={{ height: "100%" }}>
<CardActionArea sx={{ height: "100%" }}> <CardActionArea sx={{ height: "100%" }}>
<CardContent onClick={props.onClick} sx={{ height: "100%" }}> <CardContent onClick={props.onClick} sx={{ height: "100%" }}>
{labelStyle && {labelStyle && (
<div style={{ <div
style={{
position: "absolute", position: "absolute",
top: "0", top: "0",
right: "15px", right: "15px",
padding: "2px 10px", padding: "2px 10px",
borderRadius: "3px", borderRadius: "3px",
...labelStyle ...labelStyle,
}}>{labelText}</div> }}
} >
{labelText}
</div>
)}
<Typography variant="subtitle1" component="div"> <Typography variant="subtitle1" component="div">
{tier.name || t("account_basics_tier_free")} {tier.name || t("account_basics_tier_free")}
</Typography> </Typography>
<div> <div>
<Typography component="span" variant="h4" sx={{ fontWeight: 500, marginRight: "3px" }}>{formatPrice(monthlyPrice)}</Typography> <Typography
{monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}</>} component="span"
variant="h4"
sx={{ fontWeight: 500, marginRight: "3px" }}
>
{formatPrice(monthlyPrice)}
</Typography>
{monthlyPrice > 0 && (
<>/ {t("account_upgrade_dialog_tier_price_per_month")}</>
)}
</div> </div>
<List dense> <List dense>
{tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>} {tier.limits.reservations > 0 && (
<Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature> <Feature>
<Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature> {t("account_upgrade_dialog_tier_features_reservations", {
{tier.limits.calls > 0 && <Feature>{t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}</Feature>} reservations: tier.limits.reservations,
<Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature> count: tier.limits.reservations,
{tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>} })}
{tier.limits.calls === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_calls")}</NoFeature>} </Feature>
)}
<Feature>
{t("account_upgrade_dialog_tier_features_messages", {
messages: formatNumber(tier.limits.messages),
count: tier.limits.messages,
})}
</Feature>
<Feature>
{t("account_upgrade_dialog_tier_features_emails", {
emails: formatNumber(tier.limits.emails),
count: tier.limits.emails,
})}
</Feature>
{tier.limits.calls > 0 && (
<Feature>
{t("account_upgrade_dialog_tier_features_calls", {
calls: formatNumber(tier.limits.calls),
count: tier.limits.calls,
})}
</Feature>
)}
<Feature>
{t(
"account_upgrade_dialog_tier_features_attachment_file_size",
{ filesize: formatBytes(tier.limits.attachment_file_size, 0) }
)}
</Feature>
{tier.limits.reservations === 0 && (
<NoFeature>
{t("account_upgrade_dialog_tier_features_no_reservations")}
</NoFeature>
)}
{tier.limits.calls === 0 && (
<NoFeature>
{t("account_upgrade_dialog_tier_features_no_calls")}
</NoFeature>
)}
</List> </List>
{tier.prices && props.interval === SubscriptionInterval.MONTH && {tier.prices && props.interval === SubscriptionInterval.MONTH && (
<Typography variant="body2" color="gray"> <Typography variant="body2" color="gray">
{t("account_upgrade_dialog_tier_price_billed_monthly", { price: formatPrice(tier.prices.month*12) })} {t("account_upgrade_dialog_tier_price_billed_monthly", {
price: formatPrice(tier.prices.month * 12),
})}
</Typography> </Typography>
} )}
{tier.prices && props.interval === SubscriptionInterval.YEAR && {tier.prices && props.interval === SubscriptionInterval.YEAR && (
<Typography variant="body2" color="gray"> <Typography variant="body2" color="gray">
{t("account_upgrade_dialog_tier_price_billed_yearly", { price: formatPrice(tier.prices.year), save: formatPrice(tier.prices.month*12-tier.prices.year) })} {t("account_upgrade_dialog_tier_price_billed_yearly", {
price: formatPrice(tier.prices.year),
save: formatPrice(tier.prices.month * 12 - tier.prices.year),
})}
</Typography> </Typography>
} )}
</CardContent> </CardContent>
</CardActionArea> </CardActionArea>
</Card> </Card>
</Box> </Box>
); );
} };
const Feature = (props) => { const Feature = (props) => {
return <FeatureItem feature={true}>{props.children}</FeatureItem>; return <FeatureItem feature={true}>{props.children}</FeatureItem>;
} };
const NoFeature = (props) => { const NoFeature = (props) => {
return <FeatureItem feature={false}>{props.children}</FeatureItem>; return <FeatureItem feature={false}>{props.children}</FeatureItem>;
} };
const FeatureItem = (props) => { const FeatureItem = (props) => {
return ( return (
<ListItem disableGutters sx={{m: 0, p: 0}}> <ListItem disableGutters sx={{ m: 0, p: 0 }}>
<ListItemIcon sx={{minWidth: "24px"}}> <ListItemIcon sx={{ minWidth: "24px" }}>
{props.feature && <Check fontSize="small" sx={{ color: "#338574" }}/>} {props.feature && <Check fontSize="small" sx={{ color: "#338574" }} />}
{!props.feature && <Close fontSize="small" sx={{ color: "gray" }}/>} {!props.feature && <Close fontSize="small" sx={{ color: "gray" }} />}
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
sx={{mt: "2px", mb: "2px"}} sx={{ mt: "2px", mb: "2px" }}
primary={ primary={<Typography variant="body1">{props.children}</Typography>}
<Typography variant="body1">
{props.children}
</Typography>
}
/> />
</ListItem> </ListItem>
); );
}; };
@ -355,13 +488,13 @@ const Action = {
REDIRECT_SIGNUP: 1, REDIRECT_SIGNUP: 1,
CREATE_SUBSCRIPTION: 2, CREATE_SUBSCRIPTION: 2,
UPDATE_SUBSCRIPTION: 3, UPDATE_SUBSCRIPTION: 3,
CANCEL_SUBSCRIPTION: 4 CANCEL_SUBSCRIPTION: 4,
}; };
const Banner = { const Banner = {
CANCEL_WARNING: 1, CANCEL_WARNING: 1,
PRORATION_INFO: 2, PRORATION_INFO: 2,
RESERVATIONS_WARNING: 3 RESERVATIONS_WARNING: 3,
}; };
export default UpgradeDialog; export default UpgradeDialog;

View file

@ -1,7 +1,7 @@
import {useNavigate, useParams} from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import {disallowedTopic, expandSecureUrl, topicUrl} from "../app/utils"; import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils";
import notifier from "../app/Notifier"; import notifier from "../app/Notifier";
import routes from "./routes"; import routes from "./routes";
import connectionManager from "../app/ConnectionManager"; import connectionManager from "../app/ConnectionManager";
@ -9,7 +9,7 @@ import poller from "../app/Poller";
import pruner from "../app/Pruner"; import pruner from "../app/Pruner";
import session from "../app/Session"; import session from "../app/Session";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import {UnauthorizedError} from "../app/errors"; import { UnauthorizedError } from "../app/errors";
/** /**
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@ -20,7 +20,8 @@ export const useConnectionListeners = (account, subscriptions, users) => {
const navigate = useNavigate(); const navigate = useNavigate();
// Register listeners for incoming messages, and connection state changes // Register listeners for incoming messages, and connection state changes
useEffect(() => { useEffect(
() => {
const handleMessage = async (subscriptionId, message) => { const handleMessage = async (subscriptionId, message) => {
const subscription = await subscriptionManager.get(subscriptionId); const subscription = await subscriptionManager.get(subscriptionId);
if (subscription.internal) { if (subscription.internal) {
@ -31,25 +32,41 @@ export const useConnectionListeners = (account, subscriptions, users) => {
}; };
const handleInternalMessage = async (message) => { const handleInternalMessage = async (message) => {
console.log(`[ConnectionListener] Received message on sync topic`, message.message); console.log(
`[ConnectionListener] Received message on sync topic`,
message.message
);
try { try {
const data = JSON.parse(message.message); const data = JSON.parse(message.message);
if (data.event === "sync") { if (data.event === "sync") {
console.log(`[ConnectionListener] Triggering account sync`); console.log(`[ConnectionListener] Triggering account sync`);
await accountApi.sync(); await accountApi.sync();
} else { } else {
console.log(`[ConnectionListener] Unknown message type. Doing nothing.`); console.log(
`[ConnectionListener] Unknown message type. Doing nothing.`
);
} }
} catch (e) { } catch (e) {
console.log(`[ConnectionListener] Error parsing sync topic message`, e); console.log(
`[ConnectionListener] Error parsing sync topic message`,
e
);
} }
}; };
const handleNotification = async (subscriptionId, notification) => { const handleNotification = async (subscriptionId, notification) => {
const added = await subscriptionManager.addNotification(subscriptionId, notification); const added = await subscriptionManager.addNotification(
subscriptionId,
notification
);
if (added) { if (added) {
const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription)); const defaultClickAction = (subscription) =>
await notifier.notify(subscriptionId, notification, defaultClickAction) navigate(routes.forSubscription(subscription));
await notifier.notify(
subscriptionId,
notification,
defaultClickAction
);
} }
}; };
connectionManager.registerStateListener(subscriptionManager.updateState); connectionManager.registerStateListener(subscriptionManager.updateState);
@ -57,7 +74,7 @@ export const useConnectionListeners = (account, subscriptions, users) => {
return () => { return () => {
connectionManager.resetStateListener(); connectionManager.resetStateListener();
connectionManager.resetMessageListener(); connectionManager.resetMessageListener();
} };
}, },
// We have to disable dep checking for "navigate". This is fine, it never changes. // We have to disable dep checking for "navigate". This is fine, it never changes.
// eslint-disable-next-line // eslint-disable-next-line
@ -92,12 +109,20 @@ export const useAutoSubscribe = (subscriptions, selected) => {
return; return;
} }
setHasRun(true); setHasRun(true);
const eligible = params.topic && !selected && !disallowedTopic(params.topic); const eligible =
params.topic && !selected && !disallowedTopic(params.topic);
if (eligible) { if (eligible) {
const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : config.base_url; const baseUrl = params.baseUrl
console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); ? expandSecureUrl(params.baseUrl)
: config.base_url;
console.log(
`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`
);
(async () => { (async () => {
const subscription = await subscriptionManager.add(baseUrl, params.topic); const subscription = await subscriptionManager.add(
baseUrl,
params.topic
);
if (session.exists()) { if (session.exists()) {
try { try {
await accountApi.addSubscription(baseUrl, params.topic); await accountApi.addSubscription(baseUrl, params.topic);
@ -125,7 +150,7 @@ export const useBackgroundProcesses = () => {
pruner.startWorker(); pruner.startWorker();
accountApi.startWorker(); accountApi.startWorker();
}, []); }, []);
} };
export const useAccountListener = (setAccount) => { export const useAccountListener = (setAccount) => {
useEffect(() => { useEffect(() => {
@ -133,6 +158,6 @@ export const useAccountListener = (setAccount) => {
accountApi.sync(); // Dangle accountApi.sync(); // Dangle
return () => { return () => {
accountApi.resetListener(); accountApi.resetListener();
} };
}, []); }, []);
} };

View file

@ -1,7 +1,7 @@
import i18n from 'i18next'; import i18n from "i18next";
import Backend from 'i18next-http-backend'; import Backend from "i18next-http-backend";
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from "react-i18next";
// Translations using i18next // Translations using i18next
// - Options: https://www.i18next.com/overview/configuration-options // - Options: https://www.i18next.com/overview/configuration-options
@ -16,14 +16,14 @@ i18n
.use(LanguageDetector) .use(LanguageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
fallbackLng: 'en', fallbackLng: "en",
debug: true, debug: true,
interpolation: { interpolation: {
escapeValue: false, // not needed for react as it escapes by default escapeValue: false, // not needed for react as it escapes by default
}, },
backend: { backend: {
loadPath: '/static/langs/{{lng}}.json', loadPath: "/static/langs/{{lng}}.json",
} },
}); });
export default i18n; export default i18n;

View file

@ -1,5 +1,5 @@
import config from "../app/config"; import config from "../app/config";
import {shortUrl} from "../app/utils"; import { shortUrl } from "../app/utils";
const routes = { const routes = {
login: "/login", login: "/login",
@ -14,7 +14,7 @@ const routes = {
return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;
} }
return `/${subscription.topic}`; return `/${subscription.topic}`;
} },
}; };
export default routes; export default routes;

View file

@ -1,7 +1,7 @@
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import theme from "./theme"; import theme from "./theme";
import Container from "@mui/material/Container"; import Container from "@mui/material/Container";
import {Backdrop, styled} from "@mui/material"; import { Backdrop, styled } from "@mui/material";
export const Paragraph = styled(Typography)({ export const Paragraph = styled(Typography)({
paddingTop: 8, paddingTop: 8,
@ -9,14 +9,14 @@ export const Paragraph = styled(Typography)({
}); });
export const VerticallyCenteredContainer = styled(Container)({ export const VerticallyCenteredContainer = styled(Container)({
display: 'flex', display: "flex",
flexGrow: 1, flexGrow: 1,
flexDirection: 'column', flexDirection: "column",
justifyContent: 'center', justifyContent: "center",
alignContent: 'center', alignContent: "center",
color: theme.palette.text.primary color: theme.palette.text.primary,
}); });
export const LightboxBackdrop = styled(Backdrop)({ export const LightboxBackdrop = styled(Backdrop)({
backgroundColor: 'rgba(0, 0, 0, 0.8)' // was: 0.5 backgroundColor: "rgba(0, 0, 0, 0.8)", // was: 0.5
}); });

View file

@ -1,13 +1,13 @@
import { red } from '@mui/material/colors'; import { red } from "@mui/material/colors";
import { createTheme } from '@mui/material/styles'; import { createTheme } from "@mui/material/styles";
const theme = createTheme({ const theme = createTheme({
palette: { palette: {
primary: { primary: {
main: '#338574', main: "#338574",
}, },
secondary: { secondary: {
main: '#6cead0', main: "#6cead0",
}, },
error: { error: {
main: red.A400, main: red.A400,
@ -17,19 +17,19 @@ const theme = createTheme({
MuiListItemIcon: { MuiListItemIcon: {
styleOverrides: { styleOverrides: {
root: { root: {
minWidth: '36px', minWidth: "36px",
}, },
}, },
}, },
MuiCardContent: { MuiCardContent: {
styleOverrides: { styleOverrides: {
root: { root: {
':last-child': { ":last-child": {
paddingBottom: '16px' paddingBottom: "16px",
} },
} },
} },
} },
}, },
}); });

View file

@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from "react";
import { createRoot } from 'react-dom/client'; import { createRoot } from "react-dom/client";
import App from './components/App'; import App from "./components/App";
const root = createRoot(document.querySelector('#root')); const root = createRoot(document.querySelector("#root"));
root.render(<App />); root.render(<App />);