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

@ -6,14 +6,24 @@
// During web development, you may change values here for rapid testing. // During web development, you may change values here for rapid testing.
var config = { var config = {
base_url: window.location.origin, // Change to test against a different server base_url: window.location.origin, // Change to test against a different server
app_root: "/app", app_root: "/app",
enable_login: true, enable_login: true,
enable_signup: true, enable_signup: true,
enable_payments: false, enable_payments: false,
enable_reservations: true, enable_reservations: true,
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,44 +1,64 @@
<!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
<meta property="og:image" content="%PUBLIC_URL%/static/images/ntfy.png" /> property="og:description"
<meta property="og:url" content="https://ntfy.sh" /> 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:url" content="https://ntfy.sh" />
<!-- Never index --> <!-- Never index -->
<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"
<noscript> />
ntfy web requires JavaScript, but you can also use the <a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a> <link
or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to subscribe. rel="stylesheet"
</noscript> href="%PUBLIC_URL%/static/css/fonts.css"
<div id="root"></div> type="text/css"
<script src="%PUBLIC_URL%/config.js"></script> />
</body> </head>
<body>
<noscript>
ntfy web requires JavaScript, but you can also use the
<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>
<div id="root"></div>
<script src="%PUBLIC_URL%/config.js"></script>
</body>
</html> </html>

View file

@ -1,10 +1,11 @@
/* web app styling overrides */ /* web app styling overrides */
a, a:visited { a,
color: #338574; a:visited {
color: #338574;
} }
a:hover { a:hover {
text-decoration: none; text-decoration: none;
color: #317f6f; color: #317f6f;
} }

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

@ -1,429 +1,442 @@
import { import {
accountBillingPortalUrl, accountBillingPortalUrl,
accountBillingSubscriptionUrl, accountBillingSubscriptionUrl,
accountPasswordUrl, accountPasswordUrl,
accountPhoneUrl, accountPhoneUrl,
accountPhoneVerifyUrl, accountPhoneVerifyUrl,
accountReservationSingleUrl, accountReservationSingleUrl,
accountReservationUrl, accountReservationUrl,
accountSettingsUrl, accountSettingsUrl,
accountSubscriptionUrl, accountSubscriptionUrl,
accountTokenUrl, accountTokenUrl,
accountUrl, accountUrl,
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
class AccountApi { class AccountApi {
constructor() { constructor() {
this.timer = null; this.timer = null;
this.listener = null; // Fired when account is fetched from remote this.listener = null; // Fired when account is fetched from remote
this.tiers = null; // Cached this.tiers = null; // Cached
} }
registerListener(listener) { registerListener(listener) {
this.listener = listener; this.listener = listener;
} }
resetListener() { resetListener() {
this.listener = null; this.listener = null;
} }
async login(user) { async login(user) {
const url = accountTokenUrl(config.base_url); const url = accountTokenUrl(config.base_url);
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) {
throw new Error(`Unexpected server response: Cannot find token`); throw new Error(`Unexpected server response: Cannot find token`);
}
return json.token;
}
async logout() {
const url = accountTokenUrl(config.base_url);
console.log(
`[AccountApi] Logging out from ${url} using token ${session.token()}`
);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
});
}
async create(username, password) {
const url = accountUrl(config.base_url);
const body = JSON.stringify({
username: username,
password: password,
});
console.log(`[AccountApi] Creating user account ${url}`);
await fetchOrThrow(url, {
method: "POST",
body: body,
});
}
async get() {
const url = accountUrl(config.base_url);
console.log(`[AccountApi] Fetching user account ${url}`);
const response = await fetchOrThrow(url, {
headers: maybeWithBearerAuth({}, session.token()), // GET /v1/account endpoint can be called by anonymous
});
const account = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Account`, account);
if (this.listener) {
this.listener(account);
}
return account;
}
async delete(password) {
const url = accountUrl(config.base_url);
console.log(`[AccountApi] Deleting user account ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
password: password,
}),
});
}
async changePassword(currentPassword, newPassword) {
const url = accountPasswordUrl(config.base_url);
console.log(`[AccountApi] Changing account password ${url}`);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
password: currentPassword,
new_password: newPassword,
}),
});
}
async createToken(label, expires) {
const url = accountTokenUrl(config.base_url);
const body = {
label: label,
expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0,
};
console.log(`[AccountApi] Creating user access token ${url}`);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify(body),
});
}
async updateToken(token, label, expires) {
const url = accountTokenUrl(config.base_url);
const body = {
token: token,
label: label,
};
if (expires > 0) {
body.expires = Math.floor(Date.now() / 1000) + expires;
}
console.log(`[AccountApi] Creating user access token ${url}`);
await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify(body),
});
}
async extendToken() {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Extending user access token ${url}`);
await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
});
}
async deleteToken(token) {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Deleting user access token ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({ "X-Token": token }, session.token()),
});
}
async updateSettings(payload) {
const url = accountSettingsUrl(config.base_url);
const body = JSON.stringify(payload);
console.log(`[AccountApi] Updating user account ${url}: ${body}`);
await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: body,
});
}
async addSubscription(baseUrl, topic) {
const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify({
base_url: baseUrl,
topic: topic,
});
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: body,
});
const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription);
return subscription;
}
async updateSubscription(baseUrl, topic, payload) {
const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify({
base_url: baseUrl,
topic: topic,
...payload,
});
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: body,
});
const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription);
return subscription;
}
async deleteSubscription(baseUrl, topic) {
const url = accountSubscriptionUrl(config.base_url);
console.log(`[AccountApi] Removing user subscription ${url}`);
const headers = {
"X-BaseURL": baseUrl,
"X-Topic": topic,
};
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth(headers, session.token()),
});
}
async upsertReservation(topic, everyone) {
const url = accountReservationUrl(config.base_url);
console.log(
`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`
);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
topic: topic,
everyone: everyone,
}),
});
}
async deleteReservation(topic, deleteMessages) {
const url = accountReservationSingleUrl(config.base_url, topic);
console.log(`[AccountApi] Removing topic reservation ${url}`);
const headers = {
"X-Delete-Messages": deleteMessages ? "true" : "false",
};
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth(headers, session.token()),
});
}
async billingTiers() {
if (this.tiers) {
return this.tiers;
}
const url = tiersUrl(config.base_url);
console.log(`[AccountApi] Fetching billing tiers`);
const response = await fetchOrThrow(url); // No auth needed!
this.tiers = await response.json(); // May throw SyntaxError
return this.tiers;
}
async createBillingSubscription(tier, interval) {
console.log(
`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`
);
return await this.upsertBillingSubscription("POST", tier, interval);
}
async updateBillingSubscription(tier, interval) {
console.log(
`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`
);
return await this.upsertBillingSubscription("PUT", tier, interval);
}
async upsertBillingSubscription(method, tier, interval) {
const url = accountBillingSubscriptionUrl(config.base_url);
const response = await fetchOrThrow(url, {
method: method,
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
tier: tier,
interval: interval,
}),
});
return await response.json(); // May throw SyntaxError
}
async deleteBillingSubscription() {
const url = accountBillingSubscriptionUrl(config.base_url);
console.log(`[AccountApi] Cancelling billing subscription`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
});
}
async createBillingPortalSession() {
const url = accountBillingPortalUrl(config.base_url);
console.log(`[AccountApi] Creating billing portal session`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
});
return await response.json(); // May throw SyntaxError
}
async verifyPhoneNumber(phoneNumber, channel) {
const url = accountPhoneVerifyUrl(config.base_url);
console.log(`[AccountApi] Sending phone verification ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
channel: channel,
}),
});
}
async addPhoneNumber(phoneNumber, code) {
const url = accountPhoneUrl(config.base_url);
console.log(
`[AccountApi] Adding phone number with verification code ${url}`
);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
code: code,
}),
});
}
async deletePhoneNumber(phoneNumber, code) {
const url = accountPhoneUrl(config.base_url);
console.log(`[AccountApi] Deleting phone number ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
}),
});
}
async sync() {
try {
if (!session.token()) {
return null;
}
console.log(`[AccountApi] Syncing account`);
const account = await this.get();
if (account.language) {
await i18n.changeLanguage(account.language);
}
if (account.notification) {
if (account.notification.sound) {
await prefs.setSound(account.notification.sound);
} }
return json.token; if (account.notification.delete_after) {
} await prefs.setDeleteAfter(account.notification.delete_after);
async logout() {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token())
});
}
async create(username, password) {
const url = accountUrl(config.base_url);
const body = JSON.stringify({
username: username,
password: password
});
console.log(`[AccountApi] Creating user account ${url}`);
await fetchOrThrow(url, {
method: "POST",
body: body
});
}
async get() {
const url = accountUrl(config.base_url);
console.log(`[AccountApi] Fetching user account ${url}`);
const response = await fetchOrThrow(url, {
headers: maybeWithBearerAuth({}, session.token()) // GET /v1/account endpoint can be called by anonymous
});
const account = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Account`, account);
if (this.listener) {
this.listener(account);
} }
return account; if (account.notification.min_priority) {
} await prefs.setMinPriority(account.notification.min_priority);
async delete(password) {
const url = accountUrl(config.base_url);
console.log(`[AccountApi] Deleting user account ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
password: password
})
});
}
async changePassword(currentPassword, newPassword) {
const url = accountPasswordUrl(config.base_url);
console.log(`[AccountApi] Changing account password ${url}`);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
password: currentPassword,
new_password: newPassword
})
});
}
async createToken(label, expires) {
const url = accountTokenUrl(config.base_url);
const body = {
label: label,
expires: (expires > 0) ? Math.floor(Date.now() / 1000) + expires : 0
};
console.log(`[AccountApi] Creating user access token ${url}`);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify(body)
});
}
async updateToken(token, label, expires) {
const url = accountTokenUrl(config.base_url);
const body = {
token: token,
label: label
};
if (expires > 0) {
body.expires = Math.floor(Date.now() / 1000) + expires;
} }
console.log(`[AccountApi] Creating user access token ${url}`); }
await fetchOrThrow(url, { if (account.subscriptions) {
method: "PATCH", await subscriptionManager.syncFromRemote(
headers: withBearerAuth({}, session.token()), account.subscriptions,
body: JSON.stringify(body) account.reservations
}); );
}
return account;
} catch (e) {
console.log(`[AccountApi] Error fetching account`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
} }
}
async extendToken() { startWorker() {
const url = accountTokenUrl(config.base_url); if (this.timer !== null) {
console.log(`[AccountApi] Extending user access token ${url}`); return;
await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token())
});
} }
console.log(`[AccountApi] Starting worker`);
this.timer = setInterval(() => this.runWorker(), intervalMillis);
setTimeout(() => this.runWorker(), delayMillis);
}
async deleteToken(token) { async runWorker() {
const url = accountTokenUrl(config.base_url); if (!session.token()) {
console.log(`[AccountApi] Deleting user access token ${url}`); return;
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({"X-Token": token}, session.token())
});
} }
console.log(`[AccountApi] Extending user access token`);
async updateSettings(payload) { try {
const url = accountSettingsUrl(config.base_url); await this.extendToken();
const body = JSON.stringify(payload); } catch (e) {
console.log(`[AccountApi] Updating user account ${url}: ${body}`); console.log(`[AccountApi] Error extending user access token`, e);
await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: body
});
}
async addSubscription(baseUrl, topic) {
const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify({
base_url: baseUrl,
topic: topic
});
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: body
});
const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription);
return subscription;
}
async updateSubscription(baseUrl, topic, payload) {
const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify({
base_url: baseUrl,
topic: topic,
...payload
});
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: body
});
const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription);
return subscription;
}
async deleteSubscription(baseUrl, topic) {
const url = accountSubscriptionUrl(config.base_url);
console.log(`[AccountApi] Removing user subscription ${url}`);
const headers = {
"X-BaseURL": baseUrl,
"X-Topic": topic,
}
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth(headers, session.token()),
});
}
async upsertReservation(topic, everyone) {
const url = accountReservationUrl(config.base_url);
console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
topic: topic,
everyone: everyone
})
});
}
async deleteReservation(topic, deleteMessages) {
const url = accountReservationSingleUrl(config.base_url, topic);
console.log(`[AccountApi] Removing topic reservation ${url}`);
const headers = {
"X-Delete-Messages": deleteMessages ? "true" : "false"
}
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth(headers, session.token())
});
}
async billingTiers() {
if (this.tiers) {
return this.tiers;
}
const url = tiersUrl(config.base_url);
console.log(`[AccountApi] Fetching billing tiers`);
const response = await fetchOrThrow(url); // No auth needed!
this.tiers = await response.json(); // May throw SyntaxError
return this.tiers;
}
async createBillingSubscription(tier, interval) {
console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`);
return await this.upsertBillingSubscription("POST", tier, interval)
}
async updateBillingSubscription(tier, interval) {
console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`);
return await this.upsertBillingSubscription("PUT", tier, interval)
}
async upsertBillingSubscription(method, tier, interval) {
const url = accountBillingSubscriptionUrl(config.base_url);
const response = await fetchOrThrow(url, {
method: method,
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
tier: tier,
interval: interval
})
});
return await response.json(); // May throw SyntaxError
}
async deleteBillingSubscription() {
const url = accountBillingSubscriptionUrl(config.base_url);
console.log(`[AccountApi] Cancelling billing subscription`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token())
});
}
async createBillingPortalSession() {
const url = accountBillingPortalUrl(config.base_url);
console.log(`[AccountApi] Creating billing portal session`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token())
});
return await response.json(); // May throw SyntaxError
}
async verifyPhoneNumber(phoneNumber, channel) {
const url = accountPhoneVerifyUrl(config.base_url);
console.log(`[AccountApi] Sending phone verification ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
channel: channel
})
});
}
async addPhoneNumber(phoneNumber, code) {
const url = accountPhoneUrl(config.base_url);
console.log(`[AccountApi] Adding phone number with verification code ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
code: code
})
});
}
async deletePhoneNumber(phoneNumber, code) {
const url = accountPhoneUrl(config.base_url);
console.log(`[AccountApi] Deleting phone number ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber
})
});
}
async sync() {
try {
if (!session.token()) {
return null;
}
console.log(`[AccountApi] Syncing account`);
const account = await this.get();
if (account.language) {
await i18n.changeLanguage(account.language);
}
if (account.notification) {
if (account.notification.sound) {
await prefs.setSound(account.notification.sound);
}
if (account.notification.delete_after) {
await prefs.setDeleteAfter(account.notification.delete_after);
}
if (account.notification.min_priority) {
await prefs.setMinPriority(account.notification.min_priority);
}
}
if (account.subscriptions) {
await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations);
}
return account;
} catch (e) {
console.log(`[AccountApi] Error fetching account`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
startWorker() {
if (this.timer !== null) {
return;
}
console.log(`[AccountApi] Starting worker`);
this.timer = setInterval(() => this.runWorker(), intervalMillis);
setTimeout(() => this.runWorker(), delayMillis);
}
async runWorker() {
if (!session.token()) {
return;
}
console.log(`[AccountApi] Extending user access token`);
try {
await this.extendToken();
} catch (e) {
console.log(`[AccountApi] Error extending user access token`, e);
}
} }
}
} }
// 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
export const Permission = { 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

@ -1,118 +1,125 @@
import { import {
fetchLinesIterator, fetchLinesIterator,
maybeWithAuth, maybeWithAuth,
topicShortUrl, topicShortUrl,
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 = [];
const headers = maybeWithAuth({}, user); const headers = maybeWithAuth({}, user);
console.log(`[Api] Polling ${url}`); console.log(`[Api] Polling ${url}`);
for await (let line of fetchLinesIterator(url, headers)) { for await (let line of fetchLinesIterator(url, headers)) {
const message = JSON.parse(line); const message = JSON.parse(line);
if (message.id) { if (message.id) {
console.log(`[Api, ${shortUrl}] Received message ${line}`); console.log(`[Api, ${shortUrl}] Received message ${line}`);
messages.push(message); messages.push(message);
} }
}
return messages;
} }
return messages;
}
async publish(baseUrl, topic, message, options) { async publish(baseUrl, topic, message, options) {
const user = await userManager.get(baseUrl); const user = await userManager.get(baseUrl);
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`); console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
const headers = {}; const headers = {};
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),
}); });
} }
/** /**
* Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request. * Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request.
* Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used. * Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used.
* *
* Firefox XHR bug: * Firefox XHR bug:
* Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error, * Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error,
* so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the * so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the
* correct headers are clearly set. It's quite the odd behavior. * correct headers are clearly set. It's quite the odd behavior.
* *
* There is an example, and the bug report here: * There is an example, and the bug report here:
* - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755 * - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755
* - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345 * - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345
*/ */
publishXHR(url, body, headers, onProgress) { publishXHR(url, body, headers, onProgress) {
console.log(`[Api] Publishing message to ${url}`); console.log(`[Api] Publishing message to ${url}`);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
const send = new Promise(function (resolve, reject) { const send = new Promise(function (resolve, reject) {
xhr.open("PUT", url); xhr.open("PUT", url);
if (body.type) { if (body.type) {
xhr.overrideMimeType(body.type); xhr.overrideMimeType(body.type);
}
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value);
}
xhr.upload.addEventListener("progress", onProgress);
xhr.addEventListener("readystatechange", () => {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
console.log(
`[Api] Publish successful (HTTP ${xhr.status})`,
xhr.response
);
resolve(xhr.response);
} else if (xhr.readyState === 4) {
// Firefox bug; see description above!
console.log(
`[Api] Publish failed (HTTP ${xhr.status})`,
xhr.responseText
);
let errorText;
try {
const error = JSON.parse(xhr.responseText);
if (error.code && error.error) {
errorText = `Error ${error.code}: ${error.error}`;
} }
for (const [key, value] of Object.entries(headers)) { } catch (e) {
xhr.setRequestHeader(key, value); // Nothing
} }
xhr.upload.addEventListener("progress", onProgress); xhr.abort();
xhr.addEventListener('readystatechange', () => { reject(errorText ?? "An error occurred");
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response);
resolve(xhr.response);
} else if (xhr.readyState === 4) {
// Firefox bug; see description above!
console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText);
let errorText;
try {
const error = JSON.parse(xhr.responseText);
if (error.code && error.error) {
errorText = `Error ${error.code}: ${error.error}`;
}
} catch (e) {
// Nothing
}
xhr.abort();
reject(errorText ?? "An error occurred");
}
})
xhr.send(body);
});
send.abort = () => {
console.log(`[Api] Publish aborted by user`);
xhr.abort();
} }
return send; });
} xhr.send(body);
});
send.abort = () => {
console.log(`[Api] Publish aborted by user`);
xhr.abort();
};
return send;
}
async topicAuth(baseUrl, topic, user) { async topicAuth(baseUrl, topic, user) {
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) {
return false; // See server/server.go
} return false;
throw new Error(`Unexpected server response ${response.status}`);
} }
throw new Error(`Unexpected server response ${response.status}`);
}
} }
const api = new Api(); const api = new Api();

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,110 +15,142 @@ 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(
this.connectionId = connectionId; connectionId,
this.subscriptionId = subscriptionId; subscriptionId,
this.baseUrl = baseUrl; baseUrl,
this.topic = topic; topic,
this.user = user; user,
this.since = since; since,
this.shortUrl = topicShortUrl(baseUrl, topic); onNotification,
this.onNotification = onNotification; onStateChanged
this.onStateChanged = onStateChanged; ) {
this.connectionId = connectionId;
this.subscriptionId = subscriptionId;
this.baseUrl = baseUrl;
this.topic = topic;
this.user = user;
this.since = since;
this.shortUrl = topicShortUrl(baseUrl, topic);
this.onNotification = onNotification;
this.onStateChanged = onStateChanged;
this.ws = null;
this.retryCount = 0;
this.retryTimeout = null;
}
start() {
// Don't fetch old messages; we do that as a poll() when adding a subscription;
// we don't want to re-trigger the main view re-render potentially hundreds of times.
const wsUrl = this.wsUrl();
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`
);
this.ws = new WebSocket(wsUrl);
this.ws.onopen = (event) => {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`,
event
);
this.retryCount = 0;
this.onStateChanged(this.subscriptionId, ConnectionState.Connected);
};
this.ws.onmessage = (event) => {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`
);
try {
const data = JSON.parse(event.data);
if (data.event === "open") {
return;
}
const relevantAndValid =
data.event === "message" &&
"id" in data &&
"time" in data &&
"message" in data;
if (!relevantAndValid) {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`
);
return;
}
this.since = data.id;
this.onNotification(this.subscriptionId, data);
} catch (e) {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`
);
}
};
this.ws.onclose = (event) => {
if (event.wasClean) {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`
);
this.ws = null; this.ws = null;
this.retryCount = 0; } else {
this.retryTimeout = null; const retrySeconds =
retryBackoffSeconds[
Math.min(this.retryCount, retryBackoffSeconds.length - 1)
];
this.retryCount++;
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`
);
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
}
};
this.ws.onerror = (event) => {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`,
event
);
};
}
close() {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`
);
const socket = this.ws;
const retryTimeout = this.retryTimeout;
if (socket !== null) {
socket.close();
} }
if (retryTimeout !== null) {
start() { clearTimeout(retryTimeout);
// Don't fetch old messages; we do that as a poll() when adding a subscription;
// we don't want to re-trigger the main view re-render potentially hundreds of times.
const wsUrl = this.wsUrl();
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`);
this.ws = new WebSocket(wsUrl);
this.ws.onopen = (event) => {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event);
this.retryCount = 0;
this.onStateChanged(this.subscriptionId, ConnectionState.Connected);
}
this.ws.onmessage = (event) => {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
try {
const data = JSON.parse(event.data);
if (data.event === 'open') {
return;
}
const relevantAndValid =
data.event === 'message' &&
'id' in data &&
'time' in data &&
'message' in data;
if (!relevantAndValid) {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
return;
}
this.since = data.id;
this.onNotification(this.subscriptionId, data);
} catch (e) {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`);
}
};
this.ws.onclose = (event) => {
if (event.wasClean) {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
this.ws = null;
} else {
const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length-1)];
this.retryCount++;
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`);
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
}
};
this.ws.onerror = (event) => {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event);
};
} }
this.retryTimeout = null;
this.ws = null;
}
close() { wsUrl() {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`); const params = [];
const socket = this.ws; if (this.since) {
const retryTimeout = this.retryTimeout; params.push(`since=${this.since}`);
if (socket !== null) {
socket.close();
}
if (retryTimeout !== null) {
clearTimeout(retryTimeout);
}
this.retryTimeout = null;
this.ws = null;
} }
if (this.user) {
params.push(`auth=${this.authParam()}`);
}
const wsUrl = topicUrlWs(this.baseUrl, this.topic);
return params.length === 0 ? wsUrl : `${wsUrl}?${params.join("&")}`;
}
wsUrl() { authParam() {
const params = []; if (this.user.password) {
if (this.since) { return encodeBase64Url(basicAuth(this.user.username, this.user.password));
params.push(`since=${this.since}`);
}
if (this.user) {
params.push(`auth=${this.authParam()}`);
}
const wsUrl = topicUrlWs(this.baseUrl, this.topic);
return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`;
}
authParam() {
if (this.user.password) {
return encodeBase64Url(basicAuth(this.user.username, this.user.password));
}
return encodeBase64Url(bearerAuth(this.user.token));
} }
return encodeBase64Url(bearerAuth(this.user.token));
}
} }
export class ConnectionState { export class ConnectionState {
static Connected = "connected"; static Connected = "connected";
static Connecting = "connecting"; static Connecting = "connecting";
} }
export default Connection; export default Connection;

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).
@ -8,110 +8,130 @@ import {hashCode} from "./utils";
* as required. This is done pretty much exactly the same way as in the Android app. * as required. This is done pretty much exactly the same way as in the Android app.
*/ */
class ConnectionManager { class ConnectionManager {
constructor() { constructor() {
this.connections = new Map(); // ConnectionId -> Connection (hash, see below) this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
this.stateListener = null; // Fired when connection state changes this.stateListener = null; // Fired when connection state changes
this.messageListener = null; // Fired when new notifications arrive this.messageListener = null; // Fired when new notifications arrive
}
registerStateListener(listener) {
this.stateListener = listener;
}
resetStateListener() {
this.stateListener = null;
}
registerMessageListener(listener) {
this.messageListener = listener;
}
resetMessageListener() {
this.messageListener = null;
}
/**
* This function figures out which websocket connections should be running by comparing the
* current state of the world (connections) with the target state (targetIds).
*
* It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify
* connections. If any of them change, the connection is closed/replaced.
*/
async refresh(subscriptions, users) {
if (!subscriptions || !users) {
return;
} }
console.log(`[ConnectionManager] Refreshing connections`);
const subscriptionsWithUsersAndConnectionId = await Promise.all(
subscriptions.map(async (s) => {
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
const connectionId = await makeConnectionId(s, user);
return { ...s, user, connectionId };
})
);
const targetIds = subscriptionsWithUsersAndConnectionId.map(
(s) => s.connectionId
);
const deletedIds = Array.from(this.connections.keys()).filter(
(id) => !targetIds.includes(id)
);
registerStateListener(listener) { // Create and add new connections
this.stateListener = listener; subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
const subscriptionId = subscription.id;
const connectionId = subscription.connectionId;
const added = !this.connections.get(connectionId);
if (added) {
const baseUrl = subscription.baseUrl;
const topic = subscription.topic;
const user = subscription.user;
const since = subscription.last;
const connection = new Connection(
connectionId,
subscriptionId,
baseUrl,
topic,
user,
since,
(subscriptionId, notification) =>
this.notificationReceived(subscriptionId, notification),
(subscriptionId, state) => this.stateChanged(subscriptionId, state)
);
this.connections.set(connectionId, connection);
console.log(
`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${
user ? user.username : "anonymous"
})`
);
connection.start();
}
});
// Delete old connections
deletedIds.forEach((id) => {
console.log(`[ConnectionManager] Closing connection ${id}`);
const connection = this.connections.get(id);
this.connections.delete(id);
connection.close();
});
}
stateChanged(subscriptionId, state) {
if (this.stateListener) {
try {
this.stateListener(subscriptionId, state);
} catch (e) {
console.error(
`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`,
e
);
}
} }
}
resetStateListener() { notificationReceived(subscriptionId, notification) {
this.stateListener = null; if (this.messageListener) {
} try {
this.messageListener(subscriptionId, notification);
registerMessageListener(listener) { } catch (e) {
this.messageListener = listener; console.error(
} `[ConnectionManager] Error handling notification for ${subscriptionId}`,
e
resetMessageListener() { );
this.messageListener = null; }
}
/**
* This function figures out which websocket connections should be running by comparing the
* current state of the world (connections) with the target state (targetIds).
*
* It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify
* connections. If any of them change, the connection is closed/replaced.
*/
async refresh(subscriptions, users) {
if (!subscriptions || !users) {
return;
}
console.log(`[ConnectionManager] Refreshing connections`);
const subscriptionsWithUsersAndConnectionId = await Promise.all(subscriptions
.map(async s => {
const [user] = users.filter(u => u.baseUrl === s.baseUrl);
const connectionId = await makeConnectionId(s, user);
return {...s, user, connectionId};
}));
const targetIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId);
const deletedIds = Array.from(this.connections.keys()).filter(id => !targetIds.includes(id));
// Create and add new connections
subscriptionsWithUsersAndConnectionId.forEach(subscription => {
const subscriptionId = subscription.id;
const connectionId = subscription.connectionId;
const added = !this.connections.get(connectionId)
if (added) {
const baseUrl = subscription.baseUrl;
const topic = subscription.topic;
const user = subscription.user;
const since = subscription.last;
const connection = new Connection(
connectionId,
subscriptionId,
baseUrl,
topic,
user,
since,
(subscriptionId, notification) => this.notificationReceived(subscriptionId, notification),
(subscriptionId, state) => this.stateChanged(subscriptionId, state)
);
this.connections.set(connectionId, connection);
console.log(`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})`);
connection.start();
}
});
// Delete old connections
deletedIds.forEach(id => {
console.log(`[ConnectionManager] Closing connection ${id}`);
const connection = this.connections.get(id);
this.connections.delete(id);
connection.close();
});
}
stateChanged(subscriptionId, state) {
if (this.stateListener) {
try {
this.stateListener(subscriptionId, state);
} catch (e) {
console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e);
}
}
}
notificationReceived(subscriptionId, notification) {
if (this.messageListener) {
try {
this.messageListener(subscriptionId, notification);
} catch (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(
: hashCode(`${subscription.id}`); `${subscription.id}|${user.username}|${user.password ?? ""}|${
} user.token ?? ""
}`
)
: 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";
@ -8,89 +15,93 @@ import logo from "../img/ntfy.png";
* support this; most importantly, all iOS browsers do not support window.Notification. * support this; most importantly, all iOS browsers do not support window.Notification.
*/ */
class Notifier { class Notifier {
async notify(subscriptionId, notification, onClickFallback) { async notify(subscriptionId, notification, onClickFallback) {
if (!this.supported()) { if (!this.supported()) {
return; return;
} }
const subscription = await subscriptionManager.get(subscriptionId); const subscription = await subscriptionManager.get(subscriptionId);
const shouldNotify = await this.shouldNotify(subscription, notification); const shouldNotify = await this.shouldNotify(subscription, notification);
if (!shouldNotify) { if (!shouldNotify) {
return; return;
} }
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
const displayName = topicDisplayName(subscription); const displayName = topicDisplayName(subscription);
const message = formatMessage(notification); const message = formatMessage(notification);
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(
const n = new Notification(title, { `[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`
body: message, );
icon: logo const n = new Notification(title, {
}); body: message,
if (notification.click) { icon: logo,
n.onclick = (e) => openUrl(notification.click); });
} else { if (notification.click) {
n.onclick = () => onClickFallback(subscription); n.onclick = (e) => openUrl(notification.click);
} } else {
n.onclick = () => onClickFallback(subscription);
// Play sound
const sound = await prefs.sound();
if (sound && sound !== "none") {
try {
await playSound(sound);
} catch (e) {
console.log(`[Notifier, ${shortUrl}] Error playing audio`, e);
}
}
} }
granted() { // Play sound
return this.supported() && Notification.permission === 'granted'; const sound = await prefs.sound();
if (sound && sound !== "none") {
try {
await playSound(sound);
} catch (e) {
console.log(`[Notifier, ${shortUrl}] Error playing audio`, e);
}
} }
}
maybeRequestPermission(cb) { granted() {
if (!this.supported()) { return this.supported() && Notification.permission === "granted";
cb(false); }
return;
}
if (!this.granted()) {
Notification.requestPermission().then((permission) => {
const granted = permission === 'granted';
cb(granted);
});
}
}
async shouldNotify(subscription, notification) { maybeRequestPermission(cb) {
if (subscription.mutedUntil === 1) { if (!this.supported()) {
return false; cb(false);
} return;
const priority = (notification.priority) ? notification.priority : 3;
const minPriority = await prefs.minPriority();
if (priority < minPriority) {
return false;
}
return true;
} }
if (!this.granted()) {
Notification.requestPermission().then((permission) => {
const granted = permission === "granted";
cb(granted);
});
}
}
supported() { async shouldNotify(subscription, notification) {
return this.browserSupported() && this.contextSupported(); if (subscription.mutedUntil === 1) {
return false;
} }
const priority = notification.priority ? notification.priority : 3;
const minPriority = await prefs.minPriority();
if (priority < minPriority) {
return false;
}
return true;
}
browserSupported() { supported() {
return 'Notification' in window; return this.browserSupported() && this.contextSupported();
} }
/** browserSupported() {
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API return "Notification" in window;
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification }
*/
contextSupported() { /**
return location.protocol === 'https:' * Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
|| location.hostname.match('^127.') * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
|| location.hostname === 'localhost'; */
} contextSupported() {
return (
location.protocol === "https:" ||
location.hostname.match("^127.") ||
location.hostname === "localhost"
);
}
} }
const notifier = new Notifier(); const notifier = new Notifier();

View file

@ -5,54 +5,60 @@ const delayMillis = 2000; // 2 seconds
const intervalMillis = 300000; // 5 minutes const intervalMillis = 300000; // 5 minutes
class Poller { class Poller {
constructor() { constructor() {
this.timer = null; this.timer = null;
} }
startWorker() { startWorker() {
if (this.timer !== null) { if (this.timer !== null) {
return; return;
}
console.log(`[Poller] Starting worker`);
this.timer = setInterval(() => this.pollAll(), intervalMillis);
setTimeout(() => this.pollAll(), delayMillis);
} }
console.log(`[Poller] Starting worker`);
this.timer = setInterval(() => this.pollAll(), intervalMillis);
setTimeout(() => this.pollAll(), delayMillis);
}
async pollAll() { async pollAll() {
console.log(`[Poller] Polling all subscriptions`); console.log(`[Poller] Polling all subscriptions`);
const subscriptions = await subscriptionManager.all(); const subscriptions = await subscriptionManager.all();
for (const s of subscriptions) { for (const s of subscriptions) {
try { try {
await this.poll(s); await this.poll(s);
} catch (e) { } catch (e) {
console.log(`[Poller] Error polling ${s.id}`, e); console.log(`[Poller] Error polling ${s.id}`, e);
} }
}
} }
}
async poll(subscription) { async poll(subscription) {
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(
if (!notifications || notifications.length === 0) { subscription.baseUrl,
console.log(`[Poller] No new notifications found for ${subscription.id}`); subscription.topic,
return; since
} );
console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); if (!notifications || notifications.length === 0) {
await subscriptionManager.addNotifications(subscription.id, notifications); console.log(`[Poller] No new notifications found for ${subscription.id}`);
return;
} }
console.log(
`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`
);
await subscriptionManager.addNotifications(subscription.id, notifications);
}
pollInBackground(subscription) { pollInBackground(subscription) {
const fn = async () => { const fn = async () => {
try { try {
await this.poll(subscription); await this.poll(subscription);
} catch (e) { } catch (e) {
console.error(`[App] Error polling subscription ${subscription.id}`, e); console.error(`[App] Error polling subscription ${subscription.id}`, e);
} }
}; };
setTimeout(() => fn(), 0); setTimeout(() => fn(), 0);
} }
} }
const poller = new Poller(); const poller = new Poller();

View file

@ -1,32 +1,32 @@
import db from "./db"; 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
} }
} }
const prefs = new Prefs(); const prefs = new Prefs();

View file

@ -5,33 +5,36 @@ const delayMillis = 25000; // 25 seconds
const intervalMillis = 1800000; // 30 minutes const intervalMillis = 1800000; // 30 minutes
class Pruner { class Pruner {
constructor() { constructor() {
this.timer = null; this.timer = null;
} }
startWorker() { startWorker() {
if (this.timer !== null) { if (this.timer !== null) {
return; return;
}
console.log(`[Pruner] Starting worker`);
this.timer = setInterval(() => this.prune(), intervalMillis);
setTimeout(() => this.prune(), delayMillis);
} }
console.log(`[Pruner] Starting worker`);
this.timer = setInterval(() => this.prune(), intervalMillis);
setTimeout(() => this.prune(), delayMillis);
}
async prune() { async prune() {
const deleteAfterSeconds = await prefs.deleteAfter(); const deleteAfterSeconds = await prefs.deleteAfter();
const pruneThresholdTimestamp = Math.round(Date.now()/1000) - deleteAfterSeconds; const pruneThresholdTimestamp =
if (deleteAfterSeconds === 0) { Math.round(Date.now() / 1000) - deleteAfterSeconds;
console.log(`[Pruner] Pruning is disabled. Skipping.`); if (deleteAfterSeconds === 0) {
return; console.log(`[Pruner] Pruning is disabled. Skipping.`);
} return;
console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`);
try {
await subscriptionManager.pruneNotifications(pruneThresholdTimestamp);
} catch (e) {
console.log(`[Pruner] Error pruning old subscriptions`, e);
}
} }
console.log(
`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`
);
try {
await subscriptionManager.pruneNotifications(pruneThresholdTimestamp);
} catch (e) {
console.log(`[Pruner] Error pruning old subscriptions`, e);
}
}
} }
const pruner = new Pruner(); const pruner = new Pruner();

View file

@ -1,30 +1,30 @@
class Session { class Session {
store(username, token) { store(username, token) {
localStorage.setItem("user", username); localStorage.setItem("user", username);
localStorage.setItem("token", token); localStorage.setItem("token", token);
} }
reset() { reset() {
localStorage.removeItem("user"); localStorage.removeItem("user");
localStorage.removeItem("token"); localStorage.removeItem("token");
} }
resetAndRedirect(url) { resetAndRedirect(url) {
this.reset(); this.reset();
window.location.href = url; window.location.href = url;
} }
exists() { exists() {
return this.username() && this.token(); return this.username() && this.token();
} }
username() { username() {
return localStorage.getItem("user"); return localStorage.getItem("user");
} }
token() { token() {
return localStorage.getItem("token"); return localStorage.getItem("token");
} }
} }
const session = new Session(); const session = new Session();

View file

@ -1,192 +1,193 @@
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(
s.new = await db.notifications subscriptions.map(async (s) => {
.where({ subscriptionId: s.id, new: 1 }) s.new = await db.notifications
.count(); .where({ subscriptionId: s.id, new: 1 })
})); .count();
return subscriptions; })
);
return subscriptions;
}
async get(subscriptionId) {
return await db.subscriptions.get(subscriptionId);
}
async add(baseUrl, topic, internal) {
const id = topicUrl(baseUrl, topic);
const existingSubscription = await this.get(id);
if (existingSubscription) {
return existingSubscription;
}
const subscription = {
id: topicUrl(baseUrl, topic),
baseUrl: baseUrl,
topic: topic,
mutedUntil: 0,
last: null,
internal: internal || false,
};
await db.subscriptions.put(subscription);
return subscription;
}
async syncFromRemote(remoteSubscriptions, remoteReservations) {
console.log(
`[SubscriptionManager] Syncing subscriptions from remote`,
remoteSubscriptions
);
// Add remote subscriptions
let remoteIds = []; // = topicUrl(baseUrl, topic)
for (let i = 0; i < remoteSubscriptions.length; i++) {
const remote = remoteSubscriptions[i];
const local = await this.add(remote.base_url, remote.topic, false);
const reservation =
remoteReservations?.find(
(r) => remote.base_url === config.base_url && remote.topic === r.topic
) || null;
await this.update(local.id, {
displayName: remote.display_name, // May be undefined
reservation: reservation, // May be null!
});
remoteIds.push(local.id);
} }
async get(subscriptionId) { // Remove local subscriptions that do not exist remotely
return await db.subscriptions.get(subscriptionId) const localSubscriptions = await db.subscriptions.toArray();
for (let i = 0; i < localSubscriptions.length; i++) {
const local = localSubscriptions[i];
const remoteExists = remoteIds.includes(local.id);
if (!local.internal && !remoteExists) {
await this.remove(local.id);
}
} }
}
async add(baseUrl, topic, internal) { async updateState(subscriptionId, state) {
const id = topicUrl(baseUrl, topic); db.subscriptions.update(subscriptionId, { state: state });
const existingSubscription = await this.get(id); }
if (existingSubscription) {
return existingSubscription; async remove(subscriptionId) {
} await db.subscriptions.delete(subscriptionId);
const subscription = { await db.notifications.where({ subscriptionId: subscriptionId }).delete();
id: topicUrl(baseUrl, topic), }
baseUrl: baseUrl,
topic: topic, async first() {
mutedUntil: 0, return db.subscriptions.toCollection().first(); // May be undefined
last: null, }
internal: internal || false
}; async getNotifications(subscriptionId) {
await db.subscriptions.put(subscription); // This is quite awkward, but it is the recommended approach as per the Dexie docs.
return subscription; // It's actually fine, because the reading and filtering is quite fast. The rendering is what's
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
return db.notifications
.orderBy("time") // Sort by time first
.filter((n) => n.subscriptionId === subscriptionId)
.reverse()
.toArray();
}
async getAllNotifications() {
return db.notifications
.orderBy("time") // Efficient, see docs
.reverse()
.toArray();
}
/** Adds notification, or returns false if it already exists */
async addNotification(subscriptionId, notification) {
const exists = await db.notifications.get(notification.id);
if (exists) {
return false;
} }
try {
async syncFromRemote(remoteSubscriptions, remoteReservations) { notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions); await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab
await db.subscriptions.update(subscriptionId, {
// Add remote subscriptions last: notification.id,
let remoteIds = []; // = topicUrl(baseUrl, topic) });
for (let i = 0; i < remoteSubscriptions.length; i++) { } catch (e) {
const remote = remoteSubscriptions[i]; console.error(`[SubscriptionManager] Error adding notification`, e);
const local = await this.add(remote.base_url, remote.topic, false);
const reservation = remoteReservations?.find(r => remote.base_url === config.base_url && remote.topic === r.topic) || null;
await this.update(local.id, {
displayName: remote.display_name, // May be undefined
reservation: reservation // May be null!
});
remoteIds.push(local.id);
}
// Remove local subscriptions that do not exist remotely
const localSubscriptions = await db.subscriptions.toArray();
for (let i = 0; i < localSubscriptions.length; i++) {
const local = localSubscriptions[i];
const remoteExists = remoteIds.includes(local.id);
if (!local.internal && !remoteExists) {
await this.remove(local.id);
}
}
} }
return true;
}
async updateState(subscriptionId, state) { /** Adds/replaces notifications, will not throw if they exist */
db.subscriptions.update(subscriptionId, { state: state }); async addNotifications(subscriptionId, notifications) {
const notificationsWithSubscriptionId = notifications.map(
(notification) => ({ ...notification, subscriptionId })
);
const lastNotificationId = notifications.at(-1).id;
await db.notifications.bulkPut(notificationsWithSubscriptionId);
await db.subscriptions.update(subscriptionId, {
last: lastNotificationId,
});
}
async updateNotification(notification) {
const exists = await db.notifications.get(notification.id);
if (!exists) {
return false;
} }
try {
async remove(subscriptionId) { await db.notifications.put({ ...notification });
await db.subscriptions.delete(subscriptionId); } catch (e) {
await db.notifications console.error(`[SubscriptionManager] Error updating notification`, e);
.where({subscriptionId: subscriptionId})
.delete();
} }
return true;
}
async first() { async deleteNotification(notificationId) {
return db.subscriptions.toCollection().first(); // May be undefined await db.notifications.delete(notificationId);
} }
async getNotifications(subscriptionId) { async deleteNotifications(subscriptionId) {
// This is quite awkward, but it is the recommended approach as per the Dexie docs. await db.notifications.where({ subscriptionId: subscriptionId }).delete();
// It's actually fine, because the reading and filtering is quite fast. The rendering is what's }
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
return db.notifications async markNotificationRead(notificationId) {
.orderBy("time") // Sort by time first await db.notifications.where({ id: notificationId }).modify({ new: 0 });
.filter(n => n.subscriptionId === subscriptionId) }
.reverse()
.toArray();
}
async getAllNotifications() { async markNotificationsRead(subscriptionId) {
return db.notifications await db.notifications
.orderBy("time") // Efficient, see docs .where({ subscriptionId: subscriptionId, new: 1 })
.reverse() .modify({ new: 0 });
.toArray(); }
}
/** Adds notification, or returns false if it already exists */ async setMutedUntil(subscriptionId, mutedUntil) {
async addNotification(subscriptionId, notification) { await db.subscriptions.update(subscriptionId, {
const exists = await db.notifications.get(notification.id); mutedUntil: mutedUntil,
if (exists) { });
return false; }
}
try {
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab
await db.subscriptions.update(subscriptionId, {
last: notification.id
});
} catch (e) {
console.error(`[SubscriptionManager] Error adding notification`, e);
}
return true;
}
/** Adds/replaces notifications, will not throw if they exist */ async setDisplayName(subscriptionId, displayName) {
async addNotifications(subscriptionId, notifications) { await db.subscriptions.update(subscriptionId, {
const notificationsWithSubscriptionId = notifications displayName: displayName,
.map(notification => ({ ...notification, subscriptionId })); });
const lastNotificationId = notifications.at(-1).id; }
await db.notifications.bulkPut(notificationsWithSubscriptionId);
await db.subscriptions.update(subscriptionId, {
last: lastNotificationId
});
}
async updateNotification(notification) { async setReservation(subscriptionId, reservation) {
const exists = await db.notifications.get(notification.id); await db.subscriptions.update(subscriptionId, {
if (!exists) { reservation: reservation,
return false; });
} }
try {
await db.notifications.put({ ...notification });
} catch (e) {
console.error(`[SubscriptionManager] Error updating notification`, e);
}
return true;
}
async deleteNotification(notificationId) { async update(subscriptionId, params) {
await db.notifications.delete(notificationId); await db.subscriptions.update(subscriptionId, params);
} }
async deleteNotifications(subscriptionId) { async pruneNotifications(thresholdTimestamp) {
await db.notifications await db.notifications.where("time").below(thresholdTimestamp).delete();
.where({subscriptionId: subscriptionId}) }
.delete();
}
async markNotificationRead(notificationId) {
await db.notifications
.where({id: notificationId})
.modify({new: 0});
}
async markNotificationsRead(subscriptionId) {
await db.notifications
.where({subscriptionId: subscriptionId, new: 1})
.modify({new: 0});
}
async setMutedUntil(subscriptionId, mutedUntil) {
await db.subscriptions.update(subscriptionId, {
mutedUntil: mutedUntil
});
}
async setDisplayName(subscriptionId, displayName) {
await db.subscriptions.update(subscriptionId, {
displayName: displayName
});
}
async setReservation(subscriptionId, reservation) {
await db.subscriptions.update(subscriptionId, {
reservation: reservation
});
}
async update(subscriptionId, params) {
await db.subscriptions.update(subscriptionId, params);
}
async pruneNotifications(thresholdTimestamp) {
await db.notifications
.where("time").below(thresholdTimestamp)
.delete();
}
} }
const subscriptionManager = new SubscriptionManager(); const subscriptionManager = new SubscriptionManager();

View file

@ -2,45 +2,45 @@ import db from "./db";
import session from "./Session"; import session from "./Session";
class UserManager { class UserManager {
async all() { async all() {
const users = await db.users.toArray(); const users = await db.users.toArray();
if (session.exists()) { if (session.exists()) {
users.unshift(this.localUser()); users.unshift(this.localUser());
}
return users;
} }
return users;
}
async get(baseUrl) { async get(baseUrl) {
if (session.exists() && baseUrl === config.base_url) { if (session.exists() && baseUrl === config.base_url) {
return this.localUser(); return this.localUser();
}
return db.users.get(baseUrl);
} }
return db.users.get(baseUrl);
}
async save(user) { async save(user) {
if (session.exists() && user.baseUrl === config.base_url) { if (session.exists() && user.baseUrl === config.base_url) {
return; return;
}
await db.users.put(user);
} }
await db.users.put(user);
}
async delete(baseUrl) { async delete(baseUrl) {
if (session.exists() && baseUrl === config.base_url) { if (session.exists() && baseUrl === config.base_url) {
return; return;
}
await db.users.delete(baseUrl);
} }
await db.users.delete(baseUrl);
}
localUser() { localUser() {
if (!session.exists()) { if (!session.exists()) {
return null; return null;
}
return {
baseUrl: config.base_url,
username: session.username(),
token: session.token() // Not "password"!
};
} }
return {
baseUrl: config.base_url,
username: session.username(),
token: session.token(), // Not "password"!
};
}
} }
const userManager = new UserManager(); const userManager = new UserManager();

View file

@ -3,7 +3,7 @@ const config = window.config;
// The backend returns an empty base_url for the config struct, // The backend returns an empty base_url for the config struct,
// so the frontend (hey, that's us!) can use the current location. // so the frontend (hey, that's us!) can use the current location.
if (!config.base_url || config.base_url === "") { if (!config.base_url || config.base_url === "") {
config.base_url = window.location.origin; config.base_url = window.location.origin;
} }
export default config; export default config;

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

@ -1,66 +1,80 @@
// This is a subset of, and the counterpart to errors.go // This is a subset of, and the counterpart to errors.go
export const fetchOrThrow = async (url, options) => { export const fetchOrThrow = async (url, options) => {
const response = await fetch(url, options); const response = await fetch(url, options);
if (response.status !== 200) { if (response.status !== 200) {
await throwAppError(response); await throwAppError(response);
} }
return response; // Promise! return response; // Promise!
}; };
export const throwAppError = async (response) => { export const throwAppError = async (response) => {
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
console.log(`[Error] HTTP ${response.status}`, response); console.log(`[Error] HTTP ${response.status}`, response);
throw new UnauthorizedError(); throw new UnauthorizedError();
}
const error = await maybeToJson(response);
if (error?.code) {
console.log(
`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${
error.error || ""
}`,
response
);
if (error.code === UserExistsError.CODE) {
throw new UserExistsError();
} else if (error.code === TopicReservedError.CODE) {
throw new TopicReservedError();
} else if (error.code === AccountCreateLimitReachedError.CODE) {
throw new AccountCreateLimitReachedError();
} else if (error.code === IncorrectPasswordError.CODE) {
throw new IncorrectPasswordError();
} else if (error?.error) {
throw new Error(`Error ${error.code}: ${error.error}`);
} }
const error = await maybeToJson(response); }
if (error?.code) { console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response);
console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response); throw new Error(`Unexpected response ${response.status}`);
if (error.code === UserExistsError.CODE) {
throw new UserExistsError();
} else if (error.code === TopicReservedError.CODE) {
throw new TopicReservedError();
} else if (error.code === AccountCreateLimitReachedError.CODE) {
throw new AccountCreateLimitReachedError();
} else if (error.code === IncorrectPasswordError.CODE) {
throw new IncorrectPasswordError();
} else if (error?.error) {
throw new Error(`Error ${error.code}: ${error.error}`);
}
}
console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response);
throw new Error(`Unexpected response ${response.status}`);
}; };
const maybeToJson = async (response) => { const maybeToJson = async (response) => {
try { try {
return await response.json(); return await response.json();
} 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,300 +7,316 @@ 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}`];
export const expandSecureUrl = (url) => `https://${url}`; 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) {
return subscription.displayName; return subscription.displayName;
} else if (subscription.baseUrl === config.base_url) { } else if (subscription.baseUrl === config.base_url) {
return subscription.topic; return subscription.topic;
} }
return topicShortUrl(subscription.baseUrl, subscription.topic); return topicShortUrl(subscription.baseUrl, subscription.topic);
}; };
// 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) {
return formatTitle(m); return formatTitle(m);
} }
return fallback; return fallback;
}; };
export const formatTitle = (m) => { export const formatTitle = (m) => {
const emojiList = toEmojis(m.tags); const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) { if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.title}`; return `${emojiList.join(" ")} ${m.title}`;
} else { } else {
return m.title; return m.title;
} }
}; };
export const formatMessage = (m) => { export const formatMessage = (m) => {
if (m.title) { if (m.title) {
return m.message; return m.message;
} else {
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.message}`;
} else { } else {
const emojiList = toEmojis(m.tags); return m.message;
if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.message}`;
} else {
return m.message;
}
} }
}
}; };
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) {
return withBasicAuth(headers, user.username, user.password); return withBasicAuth(headers, user.username, user.password);
} else if (user && user.token) { } else if (user && user.token) {
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;
for (let index = arr.length - 1; index > 0; index--) { for (let index = arr.length - 1; index > 0; index--) {
j = Math.floor(Math.random() * (index + 1)); j = Math.floor(Math.random() * (index + 1));
x = arr[index]; x = arr[index];
arr[index] = arr[j]; arr[index] = arr[j];
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) => {
const audio = new Audio(sounds[id].file); const audio = new Audio(sounds[id].file);
return audio.play(); return audio.play();
}; };
// 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;
for (;;) { for (;;) {
let result = re.exec(chunk); let result = re.exec(chunk);
if (!result) { if (!result) {
if (readerDone) { if (readerDone) {
break; break;
} }
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;
}
yield chunk.substring(startIndex, result.index);
startIndex = re.lastIndex;
}
if (startIndex < chunk.length) {
yield chunk.substr(startIndex); // last line didn't end in a newline char
} }
yield chunk.substring(startIndex, result.index);
startIndex = re.lastIndex;
}
if (startIndex < chunk.length) {
yield chunk.substr(startIndex); // last line didn't end in a newline char
}
} }
export const randomAlphanumericString = (len) => { export const randomAlphanumericString = (len) => {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const alphabet =
let id = ""; "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
for (let i = 0; i < len; i++) { let id = "";
id += alphabet[(Math.random() * alphabet.length) | 0]; for (let i = 0; i < len; i++) {
} id += alphabet[(Math.random() * alphabet.length) | 0];
return id; }
} return id;
};

File diff suppressed because it is too large Load diff

View file

@ -5,179 +5,219 @@ 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";
import { SubscriptionPopup } from "./SubscriptionPopup"; import { SubscriptionPopup } from "./SubscriptionPopup";
const ActionBar = (props) => { const ActionBar = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
let title = "ntfy"; let title = "ntfy";
if (props.selected) { if (props.selected) {
title = topicDisplayName(props.selected); title = topicDisplayName(props.selected);
} else if (location.pathname === routes.settings) { } else if (location.pathname === routes.settings) {
title = t("action_bar_settings"); title = t("action_bar_settings");
} else if (location.pathname === routes.account) { } else if (location.pathname === routes.account) {
title = t("action_bar_account"); title = t("action_bar_account");
} }
return ( return (
<AppBar position="fixed" sx={{ <AppBar
width: '100%', position="fixed"
zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300) sx={{
ml: { sm: `${Navigation.width}px` } width: "100%",
}}> zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
<Toolbar sx={{ ml: { sm: `${Navigation.width}px` },
pr: '24px', }}
background: "linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%)" >
}}> <Toolbar
<IconButton sx={{
color="inherit" pr: "24px",
edge="start" background:
aria-label={t("action_bar_show_menu")} "linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%)",
onClick={props.onMobileDrawerToggle} }}
sx={{ mr: 2, display: { sm: 'none' } }} >
> <IconButton
<MenuIcon /> color="inherit"
</IconButton> edge="start"
<Box aria-label={t("action_bar_show_menu")}
component="img" onClick={props.onMobileDrawerToggle}
src={logo} sx={{ mr: 2, display: { sm: "none" } }}
alt={t("action_bar_logo_alt")} >
sx={{ <MenuIcon />
display: { xs: 'none', sm: 'block' }, </IconButton>
marginRight: '10px', <Box
height: '28px' component="img"
}} src={logo}
/> alt={t("action_bar_logo_alt")}
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}> sx={{
{title} display: { xs: "none", sm: "block" },
</Typography> marginRight: "10px",
{props.selected && height: "28px",
<SettingsIcons }}
subscription={props.selected} />
onUnsubscribe={props.onUnsubscribe} <Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
/>} {title}
<ProfileIcon/> </Typography>
</Toolbar> {props.selected && (
</AppBar> <SettingsIcons
); subscription={props.selected}
onUnsubscribe={props.onUnsubscribe}
/>
)}
<ProfileIcon />
</Toolbar>
</AppBar>
);
}; };
const SettingsIcons = (props) => { const SettingsIcons = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null); const [anchorEl, setAnchorEl] = useState(null);
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"
</IconButton> size="large"
<IconButton color="inherit" size="large" edge="end" onClick={(ev) => setAnchorEl(ev.currentTarget)} aria-label={t("action_bar_toggle_action_menu")}> edge="end"
<MoreVertIcon/> onClick={handleToggleMute}
</IconButton> aria-label={t("action_bar_toggle_mute")}
<SubscriptionPopup >
subscription={subscription} {subscription.mutedUntil ? (
anchor={anchorEl} <NotificationsOffIcon />
placement="right" ) : (
onClose={() => setAnchorEl(null)} <NotificationsIcon />
/> )}
</> </IconButton>
); <IconButton
color="inherit"
size="large"
edge="end"
onClick={(ev) => setAnchorEl(ev.currentTarget)}
aria-label={t("action_bar_toggle_action_menu")}
>
<MoreVertIcon />
</IconButton>
<SubscriptionPopup
subscription={subscription}
anchor={anchorEl}
placement="right"
onClose={() => setAnchorEl(null)}
/>
</>
);
}; };
const ProfileIcon = () => { const ProfileIcon = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null); const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = (event) => { const handleClick = (event) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
}; };
const handleClose = () => { const handleClose = () => {
setAnchorEl(null); setAnchorEl(null);
}; };
const handleLogout = async () => { const handleLogout = async () => {
try { try {
await accountApi.logout(); await accountApi.logout();
await db.delete(); await db.delete();
} finally { } finally {
session.resetAndRedirect(routes.app); session.resetAndRedirect(routes.app);
} }
}; };
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"
</IconButton> size="large"
} edge="end"
{!session.exists() && config.enable_login && onClick={handleClick}
<Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{m: 1}} aria-label={t("action_bar_sign_in")}> aria-label={t("action_bar_profile_title")}
{t("action_bar_sign_in")} >
</Button> <AccountCircleIcon />
} </IconButton>
{!session.exists() && config.enable_signup && )}
<Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}> {!session.exists() && config.enable_login && (
{t("action_bar_sign_up")} <Button
</Button> color="inherit"
} variant="text"
<PopupMenu onClick={() => navigate(routes.login)}
horizontal="right" sx={{ m: 1 }}
anchorEl={anchorEl} aria-label={t("action_bar_sign_in")}
open={open} >
onClose={handleClose} {t("action_bar_sign_in")}
> </Button>
<MenuItem onClick={() => navigate(routes.account)}> )}
<ListItemIcon> {!session.exists() && config.enable_signup && (
<Person /> <Button
</ListItemIcon> color="inherit"
<b>{session.username()}</b> variant="outlined"
</MenuItem> onClick={() => navigate(routes.signup)}
<Divider /> aria-label={t("action_bar_sign_up")}
<MenuItem onClick={() => navigate(routes.settings)}> >
<ListItemIcon> {t("action_bar_sign_up")}
<Settings fontSize="small" /> </Button>
</ListItemIcon> )}
{t("action_bar_profile_settings")} <PopupMenu
</MenuItem> horizontal="right"
<MenuItem onClick={handleLogout}> anchorEl={anchorEl}
<ListItemIcon> open={open}
<Logout fontSize="small" /> onClose={handleClose}
</ListItemIcon> >
{t("action_bar_profile_logout")} <MenuItem onClick={() => navigate(routes.account)}>
</MenuItem> <ListItemIcon>
</PopupMenu> <Person />
</> </ListItemIcon>
); <b>{session.username()}</b>
</MenuItem>
<Divider />
<MenuItem onClick={() => navigate(routes.settings)}>
<ListItemIcon>
<Settings fontSize="small" />
</ListItemIcon>
{t("action_bar_profile_settings")}
</MenuItem>
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<Logout fontSize="small" />
</ListItemIcon>
{t("action_bar_profile_logout")}
</MenuItem>
</PopupMenu>
</>
);
}; };
export default ActionBar; export default ActionBar;

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";
@ -29,119 +45,145 @@ import Account from "./Account";
export const AccountContext = createContext(null); export const AccountContext = createContext(null);
const App = () => { const App = () => {
const [account, setAccount] = useState(null); const [account, setAccount] = useState(null);
return ( return (
<Suspense fallback={<Loader />}> <Suspense fallback={<Loader />}>
<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}
</Route> element={<SingleSubscription />}
</Routes> />
</ErrorBoundary> <Route
</AccountContext.Provider> path={routes.subscriptionExternal}
</ThemeProvider> element={<SingleSubscription />}
</BrowserRouter> />
</Suspense> </Route>
); </Routes>
} </ErrorBoundary>
</AccountContext.Provider>
</ThemeProvider>
</BrowserRouter>
</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(
const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); notifier.granted()
const users = useLiveQuery(() => userManager.all()); );
const subscriptions = useLiveQuery(() => subscriptionManager.all()); const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const subscriptionsWithoutInternal = subscriptions?.filter(s => !s.internal); const users = useLiveQuery(() => userManager.all());
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; const subscriptions = useLiveQuery(() => subscriptionManager.all());
const [selected] = (subscriptionsWithoutInternal || []).filter(s => { const subscriptionsWithoutInternal = subscriptions?.filter(
return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) (s) => !s.internal
|| (config.base_url === s.baseUrl && params.topic === s.topic) );
}); const newNotificationsCount =
subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
useConnectionListeners(account, subscriptions, users); const [selected] = (subscriptionsWithoutInternal || []).filter((s) => {
useAccountListener(setAccount)
useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
return ( return (
<Box sx={{display: 'flex'}}> (params.baseUrl &&
<ActionBar expandUrl(params.baseUrl).includes(s.baseUrl) &&
selected={selected} params.topic === s.topic) ||
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} (config.base_url === s.baseUrl && params.topic === s.topic)
/>
<Navigation
subscriptions={subscriptionsWithoutInternal}
selectedSubscription={selected}
notificationsGranted={notificationsGranted}
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onNotificationGranted={setNotificationsGranted}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
/>
<Main>
<Toolbar/>
<Outlet context={{
subscriptions: subscriptionsWithoutInternal,
selected: selected
}}/>
</Main>
<Messaging
selected={selected}
dialogOpenMode={sendDialogOpenMode}
onDialogOpenModeChange={setSendDialogOpenMode}
/>
</Box>
); );
} });
useConnectionListeners(account, subscriptions, users);
useAccountListener(setAccount);
useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
return (
<Box sx={{ display: "flex" }}>
<ActionBar
selected={selected}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
/>
<Navigation
subscriptions={subscriptionsWithoutInternal}
selectedSubscription={selected}
notificationsGranted={notificationsGranted}
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onNotificationGranted={setNotificationsGranted}
onPublishMessageClick={() =>
setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)
}
/>
<Main>
<Toolbar />
<Outlet
context={{
subscriptions: subscriptionsWithoutInternal,
selected: selected,
}}
/>
</Main>
<Messaging
selected={selected}
dialogOpenMode={sendDialogOpenMode}
onDialogOpenModeChange={setSendDialogOpenMode}
/>
</Box>
);
};
const Main = (props) => { const Main = (props) => {
return ( return (
<Box <Box
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]
{props.children} : theme.palette.grey[900],
</Box> }}
); >
{props.children}
</Box>
);
}; };
const Loader = () => ( const Loader = () => (
<Backdrop <Backdrop
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]
<CircularProgress color="success" disableShrink /> : theme.palette.grey[900],
</Backdrop> }}
>
<CircularProgress color="success" disableShrink />
</Backdrop>
); );
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,43 +5,43 @@ 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();
const type = props.type; const type = props.type;
let imageFile, imageLabel; let imageFile, imageLabel;
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") {
imageFile = fileApp; imageFile = fileApp;
imageLabel = t("notifications_attachment_file_app"); imageLabel = t("notifications_attachment_file_app");
} else { } else {
imageFile = fileDocument; imageFile = fileDocument;
imageLabel = t("notifications_attachment_file_document"); imageLabel = t("notifications_attachment_file_document");
} }
return ( return (
<Box <Box
component="img" component="img"
src={imageFile} src={imageFile}
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,29 +1,29 @@
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";
const AvatarBox = (props) => { 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
sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }}
src={logo} src={logo}
variant="rounded" variant="rounded"
/> />
{props.children} {props.children}
</Box> </Box>
); );
} };
export default AvatarBox; export default AvatarBox;

View file

@ -4,30 +4,30 @@ import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions"; 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 }}
component="div" >
aria-live="polite" <DialogContentText
sx={{ component="div"
margin: '0px', aria-live="polite"
paddingTop: '12px', sx={{
paddingBottom: '4px' margin: "0px",
}} paddingTop: "12px",
> paddingBottom: "4px",
{props.status} }}
</DialogContentText> >
<DialogActions sx={{paddingRight: 2}}> {props.status}
{props.children} </DialogContentText>
</DialogActions> <DialogActions sx={{ paddingRight: 2 }}>{props.children}</DialogActions>
</Box> </Box>
); );
}; };
export default DialogFooter; export default DialogFooter;

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,163 +17,185 @@ 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 =
if (supportedEmoji) { unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`; if (supportedEmoji) {
const emojiWithSearchBase = { ...emoji, searchBase: searchBase }; const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(
emojisByCategory[emoji.category].push(emojiWithSearchBase); " "
} )} ${emoji.tags.join(" ")}`;
} catch (e) { const emojiWithSearchBase = { ...emoji, searchBase: searchBase };
// Nothing. Ignore. emojisByCategory[emoji.category].push(emojiWithSearchBase);
} }
} catch (e) {
// Nothing. Ignore.
}
}); });
const EmojiPicker = (props) => { const EmojiPicker = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const open = Boolean(props.anchorEl); const open = Boolean(props.anchorEl);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const searchRef = useRef(null); const searchRef = useRef(null);
const searchFields = splitNoEmpty(search.toLowerCase(), " "); const searchFields = splitNoEmpty(search.toLowerCase(), " ");
const handleSearchClear = () => { const handleSearchClear = () => {
setSearch(""); setSearch("");
searchRef.current?.focus(); searchRef.current?.focus();
}; };
return ( return (
<Popper <Popper
open={open} open={open}
anchorEl={props.anchorEl} anchorEl={props.anchorEl}
placement="bottom-start" placement="bottom-start"
sx={{ zIndex: 10005 }} sx={{ zIndex: 10005 }}
transition transition
> >
{({ TransitionProps }) => ( {({ TransitionProps }) => (
<ClickAwayListener onClickAway={props.onClose}> <ClickAwayListener onClickAway={props.onClose}>
<Fade {...TransitionProps} timeout={350}> <Fade {...TransitionProps} timeout={350}>
<Box sx={{ <Box
boxShadow: 3, sx={{
padding: 2, boxShadow: 3,
paddingRight: 0, padding: 2,
paddingBottom: 1, paddingRight: 0,
width: "380px", paddingBottom: 1,
maxHeight: "300px", width: "380px",
backgroundColor: 'background.paper', maxHeight: "300px",
overflowY: "scroll" backgroundColor: "background.paper",
}}> overflowY: "scroll",
<TextField }}
inputRef={searchRef} >
margin="dense" <TextField
size="small" inputRef={searchRef}
placeholder={t("emoji_picker_search_placeholder")} margin="dense"
value={search} size="small"
onChange={ev => setSearch(ev.target.value)} placeholder={t("emoji_picker_search_placeholder")}
type="text" value={search}
variant="standard" onChange={(ev) => setSearch(ev.target.value)}
fullWidth type="text"
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }} variant="standard"
inputProps={{ fullWidth
role: "searchbox", sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
"aria-label": t("emoji_picker_search_placeholder") inputProps={{
}} role: "searchbox",
InputProps={{ "aria-label": t("emoji_picker_search_placeholder"),
endAdornment: }}
<InputAdornment position="end" sx={{ display: (search) ? '' : 'none' }}> InputProps={{
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}> endAdornment: (
<Close/> <InputAdornment
</IconButton> position="end"
</InputAdornment> sx={{ display: search ? "" : "none" }}
}} >
/> <IconButton
<Box sx={{ display: "flex", flexWrap: "wrap", paddingRight: 0, marginTop: 1 }}> size="small"
{Object.keys(emojisByCategory).map(category => onClick={handleSearchClear}
<Category edge="end"
key={category} aria-label={t("emoji_picker_search_clear")}
title={category} >
emojis={emojisByCategory[category]} <Close />
search={searchFields} </IconButton>
onPick={props.onEmojiPick} </InputAdornment>
/> ),
)} }}
</Box> />
</Box> <Box
</Fade> sx={{
</ClickAwayListener> display: "flex",
)} flexWrap: "wrap",
</Popper> paddingRight: 0,
); marginTop: 1,
}}
>
{Object.keys(emojisByCategory).map((category) => (
<Category
key={category}
title={category}
emojis={emojisByCategory[category]}
search={searchFields}
onPick={props.onEmojiPick}
/>
))}
</Box>
</Box>
</Fade>
</ClickAwayListener>
)}
</Popper>
);
}; };
const Category = (props) => { 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])}
/> />
)} ))}
</> </>
); );
}; };
const Emoji = (props) => { const Emoji = (props) => {
const emoji = props.emoji; const emoji = props.emoji;
const matches = emojiMatches(emoji, props.search); const matches = emojiMatches(emoji, props.search);
const title = `${emoji.description} (${emoji.aliases[0]})`; const title = `${emoji.description} (${emoji.aliases[0]})`;
return ( return (
<EmojiDiv <EmojiDiv
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>
); );
}; };
const EmojiDiv = styled("div")({ const EmojiDiv = styled("div")({
fontSize: "30px", fontSize: "30px",
width: "30px", width: "30px",
height: "30px", height: "30px",
marginTop: "8px", marginTop: "8px",
marginBottom: "8px", marginBottom: "8px",
marginRight: "8px", marginRight: "8px",
lineHeight: "30px", lineHeight: "30px",
cursor: "pointer", cursor: "pointer",
opacity: 0.85, opacity: 0.85,
"&:hover": { "&:hover": {
opacity: 1 opacity: 1,
} },
}); });
const emojiMatches = (emoji, words) => { const emojiMatches = (emoji, words) => {
if (words.length === 0) { if (words.length === 0) {
return true;
}
for (const word of words) {
if (emoji.searchBase.indexOf(word) === -1) {
return false;
}
}
return true; return true;
} }
for (const word of words) {
if (emoji.searchBase.indexOf(word) === -1) {
return false;
}
}
return true;
};
export default EmojiPicker; export default EmojiPicker;

View file

@ -1,128 +1,151 @@
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) {
super(props); super(props);
this.state = { this.state = {
error: false, error: false,
originalStack: null, originalStack: null,
niceStack: null, niceStack: null,
unsupportedIndexedDB: false unsupportedIndexedDB: false,
}; };
}
componentDidCatch(error, info) {
console.error("[ErrorBoundary] Error caught", error, info);
// Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see
// - https://github.com/dexie/Dexie.js/issues/312
// - https://bugzilla.mozilla.org/show_bug.cgi?id=781982
const isUnsupportedIndexedDB =
error?.name === "InvalidStateError" ||
(error?.name === "DatabaseClosedError" &&
error?.message?.indexOf("InvalidStateError") !== -1);
if (isUnsupportedIndexedDB) {
this.handleUnsupportedIndexedDB();
} else {
this.handleError(error, info);
} }
}
componentDidCatch(error, info) { handleError(error, info) {
console.error("[ErrorBoundary] Error caught", error, info); // Immediately render original stack trace
const prettierOriginalStack = info.componentStack
.trim()
.split("\n")
.map((line) => ` at ${line}`)
.join("\n");
this.setState({
error: true,
originalStack: `${error.toString()}\n${prettierOriginalStack}`,
});
// Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see // Fetch additional info and a better stack trace
// - https://github.com/dexie/Dexie.js/issues/312 StackTrace.fromError(error).then((stack) => {
// - https://bugzilla.mozilla.org/show_bug.cgi?id=781982 console.error("[ErrorBoundary] Stacktrace fetched", stack);
const isUnsupportedIndexedDB = error?.name === "InvalidStateError" || const niceStack =
(error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1); `${error.toString()}\n` +
stack
.map(
(el) =>
` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`
)
.join("\n");
this.setState({ niceStack });
});
}
if (isUnsupportedIndexedDB) { handleUnsupportedIndexedDB() {
this.handleUnsupportedIndexedDB(); this.setState({
} else { error: true,
this.handleError(error, info); unsupportedIndexedDB: true,
} });
}
copyStack() {
let stack = "";
if (this.state.niceStack) {
stack += `${this.state.niceStack}\n\n`;
} }
stack += `${this.state.originalStack}\n`;
navigator.clipboard.writeText(stack);
}
handleError(error, info) { render() {
// Immediately render original stack trace if (this.state.error) {
const prettierOriginalStack = info.componentStack if (this.state.unsupportedIndexedDB) {
.trim() return this.renderUnsupportedIndexedDB();
.split("\n") } else {
.map(line => ` at ${line}`) return this.renderError();
.join("\n"); }
this.setState({
error: true,
originalStack: `${error.toString()}\n${prettierOriginalStack}`
});
// Fetch additional info and a better stack trace
StackTrace.fromError(error).then(stack => {
console.error("[ErrorBoundary] Stacktrace fetched", stack);
const niceStack = `${error.toString()}\n` + stack.map( el => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
this.setState({ niceStack });
});
} }
return this.props.children;
}
handleUnsupportedIndexedDB() { renderUnsupportedIndexedDB() {
this.setState({ const { t } = this.props;
error: true, return (
unsupportedIndexedDB: true <div style={{ margin: "20px" }}>
}); <h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2>
} <p style={{ maxWidth: "600px" }}>
<Trans
i18nKey="error_boundary_unsupported_indexeddb_description"
components={{
githubLink: (
<Link href="https://github.com/binwiederhier/ntfy/issues/208" />
),
discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />,
}}
/>
</p>
</div>
);
}
copyStack() { renderError() {
let stack = ""; const { t } = this.props;
if (this.state.niceStack) { return (
stack += `${this.state.niceStack}\n\n`; <div style={{ margin: "20px" }}>
} <h2>{t("error_boundary_title")} 😮</h2>
stack += `${this.state.originalStack}\n`; <p>
navigator.clipboard.writeText(stack); <Trans
} i18nKey="error_boundary_description"
components={{
render() { githubLink: (
if (this.state.error) { <Link href="https://github.com/binwiederhier/ntfy/issues" />
if (this.state.unsupportedIndexedDB) { ),
return this.renderUnsupportedIndexedDB(); discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
} else { matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />,
return this.renderError(); }}
} />
} </p>
return this.props.children; <p>
} <Button variant="outlined" onClick={() => this.copyStack()}>
{t("error_boundary_button_copy_stack_trace")}
renderUnsupportedIndexedDB() { </Button>
const { t } = this.props; </p>
return ( <h3>{t("error_boundary_stack_trace")}</h3>
<div style={{margin: '20px'}}> {this.state.niceStack ? (
<h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2> <pre>{this.state.niceStack}</pre>
<p style={{maxWidth: "600px"}}> ) : (
<Trans <>
i18nKey="error_boundary_unsupported_indexeddb_description" <CircularProgress
components={{ size="20px"
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues/208"/>, sx={{ verticalAlign: "text-bottom" }}
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>, />{" "}
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/> {t("error_boundary_gathering_info")}
}} </>
/> )}
</p> <pre>{this.state.originalStack}</pre>
</div> </div>
); );
} }
renderError() {
const { t } = this.props;
return (
<div style={{margin: '20px'}}>
<h2>{t("error_boundary_title")} 😮</h2>
<p>
<Trans
i18nKey="error_boundary_description"
components={{
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues"/>,
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
}}
/>
</p>
<p>
<Button variant="outlined" onClick={() => this.copyStack()}>{t("error_boundary_button_copy_stack_trace")}</Button>
</p>
<h3>{t("error_boundary_stack_trace")}</h3>
{this.state.niceStack
? <pre>{this.state.niceStack}</pre>
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> {t("error_boundary_gathering_info")}</>}
<pre>{this.state.originalStack}</pre>
</div>
);
}
} }
const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t

View file

@ -1,122 +1,135 @@
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();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (event) => { const handleSubmit = async (event) => {
event.preventDefault(); event.preventDefault();
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(
session.store(user.username, token); `[Login] User auth for user ${user.username} successful, token is ${token}`
window.location.href = routes.app; );
} catch (e) { session.store(user.username, token);
console.log(`[Login] User auth for user ${user.username} failed`, e); window.location.href = routes.app;
if (e instanceof UnauthorizedError) { } catch (e) {
setError(t("Login failed: Invalid username or password")); console.log(`[Login] User auth for user ${user.username} failed`, e);
} else { if (e instanceof UnauthorizedError) {
setError(e.message); setError(t("Login failed: Invalid username or password"));
} } else {
} setError(e.message);
}; }
if (!config.enable_login) {
return (
<AvatarBox>
<Typography sx={{ typography: 'h6' }}>{t("login_disabled")}</Typography>
</AvatarBox>
);
} }
};
if (!config.enable_login) {
return ( return (
<AvatarBox> <AvatarBox>
<Typography sx={{ typography: 'h6' }}> <Typography sx={{ typography: "h6" }}>{t("login_disabled")}</Typography>
{t("login_title")} </AvatarBox>
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}>
<TextField
margin="dense"
required
fullWidth
id="username"
label={t("signup_form_username")}
name="username"
value={username}
onChange={ev => setUsername(ev.target.value.trim())}
autoFocus
/>
<TextField
margin="dense"
required
fullWidth
name="password"
label={t("signup_form_password")}
type={showPassword ? "text" : "password"}
id="password"
value={password}
onChange={ev => setPassword(ev.target.value.trim())}
autoComplete="current-password"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t("signup_form_toggle_password_visibility")}
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(ev) => ev.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
disabled={username === "" || password === ""}
sx={{mt: 2, mb: 2}}
>
{t("login_form_button_submit")}
</Button>
{error &&
<Box sx={{
mb: 1,
display: 'flex',
flexGrow: 1,
justifyContent: 'center',
}}>
<WarningAmberIcon color="error" sx={{mr: 1}}/>
<Typography sx={{color: 'error.main'}}>{error}</Typography>
</Box>
}
<Box sx={{width: "100%"}}>
{/* 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>}
</Box>
</Box>
</AvatarBox>
); );
} }
return (
<AvatarBox>
<Typography sx={{ typography: "h6" }}>{t("login_title")}</Typography>
<Box
component="form"
onSubmit={handleSubmit}
noValidate
sx={{ mt: 1, maxWidth: 400 }}
>
<TextField
margin="dense"
required
fullWidth
id="username"
label={t("signup_form_username")}
name="username"
value={username}
onChange={(ev) => setUsername(ev.target.value.trim())}
autoFocus
/>
<TextField
margin="dense"
required
fullWidth
name="password"
label={t("signup_form_password")}
type={showPassword ? "text" : "password"}
id="password"
value={password}
onChange={(ev) => setPassword(ev.target.value.trim())}
autoComplete="current-password"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t("signup_form_toggle_password_visibility")}
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(ev) => ev.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
disabled={username === "" || password === ""}
sx={{ mt: 2, mb: 2 }}
>
{t("login_form_button_submit")}
</Button>
{error && (
<Box
sx={{
mb: 1,
display: "flex",
flexGrow: 1,
justifyContent: "center",
}}
>
<WarningAmberIcon color="error" sx={{ mr: 1 }} />
<Typography sx={{ color: "error.main" }}>{error}</Typography>
</Box>
)}
<Box sx={{ width: "100%" }}>
{/* 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>
)}
</Box>
</Box>
</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,108 +7,135 @@ 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("");
const [dialogKey, setDialogKey] = useState(0); const [dialogKey, setDialogKey] = useState(0);
const dialogOpenMode = props.dialogOpenMode; const dialogOpenMode = props.dialogOpenMode;
const subscription = props.selected; const subscription = props.selected;
const handleOpenDialogClick = () => { const handleOpenDialogClick = () => {
props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT); props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT);
}; };
const handleDialogClose = () => { const handleDialogClose = () => {
props.onDialogOpenModeChange(""); props.onDialogOpenModeChange("");
setDialogKey(prev => prev+1); setDialogKey((prev) => prev + 1);
}; };
return ( return (
<> <>
{subscription && <MessageBar {subscription && (
subscription={subscription} <MessageBar
message={message} subscription={subscription}
onMessageChange={setMessage} message={message}
onOpenDialogClick={handleOpenDialogClick} onMessageChange={setMessage}
/>} onOpenDialogClick={handleOpenDialogClick}
<PublishDialog />
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed )}
openMode={dialogOpenMode} <PublishDialog
baseUrl={subscription?.baseUrl ?? config.base_url} key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
topic={subscription?.topic ?? ""} openMode={dialogOpenMode}
message={message} baseUrl={subscription?.baseUrl ?? config.base_url}
onClose={handleDialogClose} topic={subscription?.topic ?? ""}
onDragEnter={() => props.onDialogOpenModeChange(prev => (prev) ? prev : PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open message={message}
onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)} onClose={handleDialogClose}
/> onDragEnter={() =>
</> 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();
const subscription = props.subscription; const subscription = props.subscription;
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(
} catch (e) { subscription.baseUrl,
console.log(`[MessageBar] Error publishing message`, e); subscription.topic,
setSnackOpen(true); props.message
} );
props.onMessageChange(""); } catch (e) {
}; console.log(`[MessageBar] Error publishing message`, e);
return ( setSnackOpen(true);
<Paper }
elevation={3} props.onMessageChange("");
sx={{ };
display: "flex", return (
position: 'fixed', <Paper
bottom: 0, elevation={3}
right: 0, sx={{
padding: 2, display: "flex",
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` }, position: "fixed",
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] bottom: 0,
}} right: 0,
> padding: 2,
<IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick} aria-label={t("message_bar_show_dialog")}> width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
<KeyboardArrowUpIcon/> backgroundColor: (theme) =>
</IconButton> theme.palette.mode === "light"
<TextField ? theme.palette.grey[100]
autoFocus : theme.palette.grey[900],
margin="dense" }}
placeholder={t("message_bar_type_message")} >
aria-label={t("message_bar_type_message")} <IconButton
role="textbox" color="inherit"
type="text" size="large"
fullWidth edge="start"
variant="standard" onClick={props.onOpenDialogClick}
value={props.message} aria-label={t("message_bar_show_dialog")}
onChange={ev => props.onMessageChange(ev.target.value)} >
onKeyPress={(ev) => { <KeyboardArrowUpIcon />
if (ev.key === 'Enter') { </IconButton>
ev.preventDefault(); <TextField
handleSendClick(); autoFocus
} margin="dense"
}} placeholder={t("message_bar_type_message")}
/> aria-label={t("message_bar_type_message")}
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick} aria-label={t("message_bar_publish")}> role="textbox"
<SendIcon/> type="text"
</IconButton> fullWidth
<Portal> variant="standard"
<Snackbar value={props.message}
open={snackOpen} onChange={(ev) => props.onMessageChange(ev.target.value)}
autoHideDuration={3000} onKeyPress={(ev) => {
onClose={() => setSnackOpen(false)} if (ev.key === "Enter") {
message={t("message_bar_error_publishing")} ev.preventDefault();
/> handleSendClick();
</Portal> }
</Paper> }}
); />
<IconButton
color="inherit"
size="large"
edge="end"
onClick={handleSendClick}
aria-label={t("message_bar_publish")}
>
<SendIcon />
</IconButton>
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("message_bar_error_publishing")}
/>
</Portal>
</Paper>
);
}; };
export default Messaging; export default Messaging;

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,360 +12,485 @@ 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
variant="temporary" variant="temporary"
role="menubar" role="menubar"
open={props.mobileDrawerOpen} open={props.mobileDrawerOpen}
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}
</Drawer> </Drawer>
{/* Big screen drawer; persistent, shown if screen is big */} {/* Big screen drawer; persistent, shown if screen is big */}
<Drawer <Drawer
open open
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}
</Drawer> </Drawer>
</Box> </Box>
); );
}; };
Navigation.width = navWidth; Navigation.width = navWidth;
const NavList = (props) => { const NavList = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0); const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
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(
handleSubscribeReset(); `[Navigation] New subscription: ${subscription.id}`,
navigate(routes.forSubscription(subscription)); subscription
handleRequestNotificationPermission();
}
const handleRequestNotificationPermission = () => {
notifier.maybeRequestPermission(granted => props.onNotificationGranted(granted))
};
const handleAccountClick = () => {
accountApi.sync(); // Dangle!
navigate(routes.account);
};
const isAdmin = account?.role === Role.ADMIN;
const isPaid = account?.billing?.subscription;
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted;
const navListPadding = (showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox) ? '0' : '';
return (
<>
<Toolbar sx={{ display: { xs: 'none', sm: 'block' } }}/>
<List component="nav" sx={{ paddingTop: navListPadding }}>
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert/>}
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
{showNotificationGrantBox && <NotificationGrantAlert 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>
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
<ListItemIcon><ChatBubble/></ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")}/>
</ListItemButton>
<SubscriptionList
subscriptions={props.subscriptions}
selectedSubscription={props.selectedSubscription}
/>
<Divider sx={{my: 1}}/>
</>}
{session.exists() &&
<ListItemButton onClick={handleAccountClick} selected={location.pathname === routes.account}>
<ListItemIcon><Person/></ListItemIcon>
<ListItemText primary={t("nav_button_account")}/>
</ListItemButton>
}
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
<ListItemIcon><SettingsIcon/></ListItemIcon>
<ListItemText primary={t("nav_button_settings")}/>
</ListItemButton>
<ListItemButton onClick={() => openUrl("/docs")}>
<ListItemIcon><ArticleIcon/></ListItemIcon>
<ListItemText primary={t("nav_button_documentation")}/>
</ListItemButton>
<ListItemButton onClick={() => props.onPublishMessageClick()}>
<ListItemIcon><Send/></ListItemIcon>
<ListItemText primary={t("nav_button_publish_message")}/>
</ListItemButton>
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
<ListItemIcon><AddIcon/></ListItemIcon>
<ListItemText primary={t("nav_button_subscribe")}/>
</ListItemButton>
{showUpgradeBanner &&
<UpgradeBanner/>
}
</List>
<SubscribeDialog
key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed
open={subscribeDialogOpen}
subscriptions={props.subscriptions}
onCancel={handleSubscribeReset}
onSuccess={handleSubscribeSubmit}
/>
</>
); );
handleSubscribeReset();
navigate(routes.forSubscription(subscription));
handleRequestNotificationPermission();
};
const handleRequestNotificationPermission = () => {
notifier.maybeRequestPermission((granted) =>
props.onNotificationGranted(granted)
);
};
const handleAccountClick = () => {
accountApi.sync(); // Dangle!
navigate(routes.account);
};
const isAdmin = account?.role === Role.ADMIN;
const isPaid = account?.billing?.subscription;
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
const showNotificationContextNotSupportedBox =
notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
const showNotificationGrantBox =
notifier.supported() &&
props.subscriptions?.length > 0 &&
!props.notificationsGranted;
const navListPadding =
showNotificationGrantBox ||
showNotificationBrowserNotSupportedBox ||
showNotificationContextNotSupportedBox
? "0"
: "";
return (
<>
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
<List component="nav" sx={{ paddingTop: navListPadding }}>
{showNotificationBrowserNotSupportedBox && (
<NotificationBrowserNotSupportedAlert />
)}
{showNotificationContextNotSupportedBox && (
<NotificationContextNotSupportedAlert />
)}
{showNotificationGrantBox && (
<NotificationGrantAlert
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>
<ListItemButton
onClick={() => navigate(routes.app)}
selected={location.pathname === config.app_root}
>
<ListItemIcon>
<ChatBubble />
</ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")} />
</ListItemButton>
<SubscriptionList
subscriptions={props.subscriptions}
selectedSubscription={props.selectedSubscription}
/>
<Divider sx={{ my: 1 }} />
</>
)}
{session.exists() && (
<ListItemButton
onClick={handleAccountClick}
selected={location.pathname === routes.account}
>
<ListItemIcon>
<Person />
</ListItemIcon>
<ListItemText primary={t("nav_button_account")} />
</ListItemButton>
)}
<ListItemButton
onClick={() => navigate(routes.settings)}
selected={location.pathname === routes.settings}
>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_settings")} />
</ListItemButton>
<ListItemButton onClick={() => openUrl("/docs")}>
<ListItemIcon>
<ArticleIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_documentation")} />
</ListItemButton>
<ListItemButton onClick={() => props.onPublishMessageClick()}>
<ListItemIcon>
<Send />
</ListItemIcon>
<ListItemText primary={t("nav_button_publish_message")} />
</ListItemButton>
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
<ListItemIcon>
<AddIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_subscribe")} />
</ListItemButton>
{showUpgradeBanner && <UpgradeBanner />}
</List>
<SubscribeDialog
key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed
open={subscribeDialogOpen}
subscriptions={props.subscriptions}
onCancel={handleSubscribeReset}
onSuccess={handleSubscribeSubmit}
/>
</>
);
}; };
const UpgradeBanner = () => { const UpgradeBanner = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0); const [dialogKey, setDialogKey] = useState(0);
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
position: "fixed", sx={{
width: `${Navigation.width - 1}px`, position: "fixed",
bottom: 0, width: `${Navigation.width - 1}px`,
mt: 'auto', bottom: 0,
background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)", mt: "auto",
}}> background:
<Divider/> "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
<ListItemButton onClick={handleClick} sx={{pt: 2, pb: 2}}> }}
<ListItemIcon><CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large"/></ListItemIcon> >
<ListItemText <Divider />
sx={{ ml: 1 }} <ListItemButton onClick={handleClick} sx={{ pt: 2, pb: 2 }}>
primary={t("nav_upgrade_banner_label")} <ListItemIcon>
secondary={t("nav_upgrade_banner_description")} <CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large" />
primaryTypographyProps={{ </ListItemIcon>
style: { <ListItemText
fontWeight: 500, sx={{ ml: 1 }}
fontSize: "1.1rem", primary={t("nav_upgrade_banner_label")}
background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)", secondary={t("nav_upgrade_banner_description")}
WebkitBackgroundClip: "text", primaryTypographyProps={{
WebkitTextFillColor: "transparent" style: {
} fontWeight: 500,
}} fontSize: "1.1rem",
secondaryTypographyProps={{ background:
style: { "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
fontSize: "1rem" WebkitBackgroundClip: "text",
} WebkitTextFillColor: "transparent",
}} },
/> }}
</ListItemButton> secondaryTypographyProps={{
<UpgradeDialog style: {
key={`upgradeDialog${dialogKey}`} fontSize: "1rem",
open={dialogOpen} },
onCancel={() => setDialogOpen(false)} }}
/> />
</Box> </ListItemButton>
); <UpgradeDialog
key={`upgradeDialog${dialogKey}`}
open={dialogOpen}
onCancel={() => setDialogOpen(false)}
/>
</Box>
);
}; };
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
return ( : 1;
<> });
{sortedSubscriptions.map(subscription => return (
<SubscriptionItem <>
key={subscription.id} {sortedSubscriptions.map((subscription) => (
subscription={subscription} <SubscriptionItem
selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id} key={subscription.id}
/>)} subscription={subscription}
</> selected={
); props.selectedSubscription &&
} props.selectedSubscription.id === subscription.id
}
/>
))}
</>
);
};
const SubscriptionItem = (props) => { const SubscriptionItem = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
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 =
? `${displayName} (${t("nav_button_connecting")})` subscription.state === ConnectionState.Connecting
: displayName; ? `${displayName} (${t("nav_button_connecting")})`
const icon = (subscription.state === ConnectionState.Connecting) : displayName;
? <CircularProgress size="24px"/> const icon =
: <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>; subscription.state === ConnectionState.Connecting ? (
<CircularProgress size="24px" />
const handleClick = async () => { ) : (
navigate(routes.forSubscription(subscription)); <Badge
await subscriptionManager.markNotificationsRead(subscription.id); badgeContent={iconBadge}
}; invisible={subscription.new === 0}
color="primary"
return ( >
<> <ChatBubbleOutlineIcon />
<ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite"> </Badge>
<ListItemIcon>{icon}</ListItemIcon>
<ListItemText primary={displayName} primaryTypographyProps={{ style: { overflow: "hidden", textOverflow: "ellipsis" } }}/>
{subscription.reservation?.everyone &&
<ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
{subscription.reservation?.everyone === Permission.READ_WRITE &&
<Tooltip title={t("prefs_reservations_table_everyone_read_write")}><PermissionReadWrite size="small"/></Tooltip>
}
{subscription.reservation?.everyone === Permission.READ_ONLY &&
<Tooltip title={t("prefs_reservations_table_everyone_read_only")}><PermissionRead 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.DENY_ALL &&
<Tooltip title={t("prefs_reservations_table_everyone_deny_all")}><PermissionDenyAll size="small"/></Tooltip>
}
</ListItemIcon>
}
{subscription.mutedUntil > 0 &&
<ListItemIcon edge="end" sx={{ minWidth: "26px" }} aria-label={t("nav_button_muted")}>
<Tooltip title={t("nav_button_muted")}><NotificationsOffOutlined /></Tooltip>
</ListItemIcon>
}
<ListItemIcon edge="end" sx={{minWidth: "26px"}}>
<IconButton
size="small"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setMenuAnchorEl(e.currentTarget);
}}
>
<MoreVert fontSize="small"/>
</IconButton>
</ListItemIcon>
</ListItemButton>
<Portal>
<SubscriptionPopup
subscription={subscription}
anchor={menuAnchorEl}
onClose={() => setMenuAnchorEl(null)}
/>
</Portal>
</>
); );
const handleClick = async () => {
navigate(routes.forSubscription(subscription));
await subscriptionManager.markNotificationsRead(subscription.id);
};
return (
<>
<ListItemButton
onClick={handleClick}
selected={props.selected}
aria-label={ariaLabel}
aria-live="polite"
>
<ListItemIcon>{icon}</ListItemIcon>
<ListItemText
primary={displayName}
primaryTypographyProps={{
style: { overflow: "hidden", textOverflow: "ellipsis" },
}}
/>
{subscription.reservation?.everyone && (
<ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
{subscription.reservation?.everyone === Permission.READ_WRITE && (
<Tooltip
title={t("prefs_reservations_table_everyone_read_write")}
>
<PermissionReadWrite size="small" />
</Tooltip>
)}
{subscription.reservation?.everyone === Permission.READ_ONLY && (
<Tooltip title={t("prefs_reservations_table_everyone_read_only")}>
<PermissionRead 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.DENY_ALL && (
<Tooltip title={t("prefs_reservations_table_everyone_deny_all")}>
<PermissionDenyAll size="small" />
</Tooltip>
)}
</ListItemIcon>
)}
{subscription.mutedUntil > 0 && (
<ListItemIcon
edge="end"
sx={{ minWidth: "26px" }}
aria-label={t("nav_button_muted")}
>
<Tooltip title={t("nav_button_muted")}>
<NotificationsOffOutlined />
</Tooltip>
</ListItemIcon>
)}
<ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
<IconButton
size="small"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setMenuAnchorEl(e.currentTarget);
}}
>
<MoreVert fontSize="small" />
</IconButton>
</ListItemIcon>
</ListItemButton>
<Portal>
<SubscriptionPopup
subscription={subscription}
anchor={menuAnchorEl}
onClose={() => setMenuAnchorEl(null)}
/>
</Portal>
</>
);
}; };
const NotificationGrantAlert = (props) => { 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}
> >
{t("alert_grant_button")} {t("alert_grant_button")}
</Button> </Button>
</Alert> </Alert>
<Divider/> <Divider />
</> </>
); );
}; };
const NotificationBrowserNotSupportedAlert = () => { 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>
</Alert> {t("alert_not_supported_description")}
<Divider/> </Typography>
</> </Alert>
); <Divider />
</>
);
}; };
const NotificationContextNotSupportedAlert = () => { 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"
</Typography> target="_blank"
</Alert> rel="noopener"
<Divider/> />
</> ),
); }}
/>
</Typography>
</Alert>
<Divider />
</>
);
}; };
export default Navigation; export default Navigation;

File diff suppressed because it is too large Load diff

View file

@ -1,48 +1,48 @@
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}
open={props.open} open={props.open}
onClose={props.onClose} onClose={props.onClose}
onClick={props.onClose} onClick={props.onClose}
TransitionComponent={Fade} TransitionComponent={Fade}
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>
); );
}; };
export default PopupMenu; export default PopupMenu;

View file

@ -1,51 +1,54 @@
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"
style={{ style={{
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
marginTop: "10px", marginTop: "10px",
marginBottom: "20px", marginBottom: "20px",
}} }}
> >
<div <div
role="cell" role="cell"
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>
</div> {props.subtitle && <em> ({props.subtitle})</em>}
<div
role="cell"
style={{
flex: '1 0 calc(60% - 50px)',
display: 'flex',
flexDirection: 'column',
justifyContent: justifyContent
}}
>
{props.children}
</div>
</div> </div>
); {props.description && (
<div>
<em>{props.description}</em>
</div>
)}
</div>
<div
role="cell"
style={{
flex: "1 0 calc(60% - 50px)",
display: "flex",
flexDirection: "column",
justifyContent: justifyContent,
}}
>
{props.children}
</div>
</div>
);
}; };

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,199 +1,239 @@
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 =
const submitButtonEnabled = validTopic(topic) && !alreadyReserved; props.reservations.filter((r) => r.topic === topic).length > 0;
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(
} catch (e) { `[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`
console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); );
if (e instanceof UnauthorizedError) { } catch (e) {
session.resetAndRedirect(routes.login); console.log(`[ReserveAddDialog] Error adding topic reservation.`, e);
} else if (e instanceof TopicReservedError) { if (e instanceof UnauthorizedError) {
setError(t("subscribe_dialog_error_topic_already_reserved")); session.resetAndRedirect(routes.login);
return; } else if (e instanceof TopicReservedError) {
} else { setError(t("subscribe_dialog_error_topic_already_reserved"));
setError(e.message); return;
return; } else {
} setError(e.message);
} return;
props.onClose(); }
}; }
props.onClose();
};
return ( return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <Dialog
<DialogTitle>{t("prefs_reservations_dialog_title_add")}</DialogTitle> open={props.open}
<DialogContent> onClose={props.onClose}
<DialogContentText> maxWidth="sm"
{t("prefs_reservations_dialog_description")} fullWidth
</DialogContentText> fullScreen={fullScreen}
{allowTopicEdit && <TextField >
autoFocus <DialogTitle>{t("prefs_reservations_dialog_title_add")}</DialogTitle>
margin="dense" <DialogContent>
id="topic" <DialogContentText>
label={t("prefs_reservations_dialog_topic_label")} {t("prefs_reservations_dialog_description")}
aria-label={t("prefs_reservations_dialog_topic_label")} </DialogContentText>
value={topic} {allowTopicEdit && (
onChange={ev => setTopic(ev.target.value)} <TextField
type="url" autoFocus
fullWidth margin="dense"
variant="standard" id="topic"
/>} label={t("prefs_reservations_dialog_topic_label")}
<ReserveTopicSelect aria-label={t("prefs_reservations_dialog_topic_label")}
value={everyone} value={topic}
onChange={setEveryone} onChange={(ev) => setTopic(ev.target.value)}
sx={{mt: 1}} type="url"
/> fullWidth
</DialogContent> variant="standard"
<DialogFooter status={error}> />
<Button onClick={props.onClose}>{t("common_cancel")}</Button> )}
<Button onClick={handleSubmit} disabled={!submitButtonEnabled}>{t("common_add")}</Button> <ReserveTopicSelect
</DialogFooter> value={everyone}
</Dialog> onChange={setEveryone}
); sx={{ mt: 1 }}
/>
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!submitButtonEnabled}>
{t("common_add")}
</Button>
</DialogFooter>
</Dialog>
);
}; };
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(
} catch (e) { `[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`
console.log(`[ReserveEditDialog] Error updating topic reservation.`, e); );
if (e instanceof UnauthorizedError) { } catch (e) {
session.resetAndRedirect(routes.login); console.log(`[ReserveEditDialog] Error updating topic reservation.`, e);
} else { if (e instanceof UnauthorizedError) {
setError(e.message); session.resetAndRedirect(routes.login);
return; } else {
} setError(e.message);
} return;
props.onClose(); }
}; }
props.onClose();
};
return ( return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <Dialog
<DialogTitle>{t("prefs_reservations_dialog_title_edit")}</DialogTitle> open={props.open}
<DialogContent> onClose={props.onClose}
<DialogContentText> maxWidth="sm"
{t("prefs_reservations_dialog_description")} fullWidth
</DialogContentText> fullScreen={fullScreen}
<ReserveTopicSelect >
value={everyone} <DialogTitle>{t("prefs_reservations_dialog_title_edit")}</DialogTitle>
onChange={setEveryone} <DialogContent>
sx={{mt: 1}} <DialogContentText>
/> {t("prefs_reservations_dialog_description")}
</DialogContent> </DialogContentText>
<DialogFooter status={error}> <ReserveTopicSelect
<Button onClick={props.onClose}>{t("common_cancel")}</Button> value={everyone}
<Button onClick={handleSubmit}>{t("common_save")}</Button> onChange={setEveryone}
</DialogFooter> sx={{ mt: 1 }}
</Dialog> />
); </DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit}>{t("common_save")}</Button>
</DialogFooter>
</Dialog>
);
}; };
export const ReserveDeleteDialog = (props) => { 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(
} catch (e) { `[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`
console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e); );
if (e instanceof UnauthorizedError) { } catch (e) {
session.resetAndRedirect(routes.login); console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e);
} else { if (e instanceof UnauthorizedError) {
setError(e.message); session.resetAndRedirect(routes.login);
return; } else {
} setError(e.message);
} return;
props.onClose(); }
}; }
props.onClose();
};
return ( return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <Dialog
<DialogTitle>{t("prefs_reservations_dialog_title_delete")}</DialogTitle> open={props.open}
<DialogContent> onClose={props.onClose}
<DialogContentText> maxWidth="sm"
{t("reservation_delete_dialog_description")} fullWidth
</DialogContentText> fullScreen={fullScreen}
<FormControl fullWidth variant="standard"> >
<Select <DialogTitle>{t("prefs_reservations_dialog_title_delete")}</DialogTitle>
value={deleteMessages} <DialogContent>
onChange={(ev) => setDeleteMessages(ev.target.value)} <DialogContentText>
sx={{ {t("reservation_delete_dialog_description")}
"& .MuiSelect-select": { </DialogContentText>
display: 'flex', <FormControl fullWidth variant="standard">
alignItems: 'center', <Select
paddingTop: "4px", value={deleteMessages}
paddingBottom: "4px", onChange={(ev) => setDeleteMessages(ev.target.value)}
} sx={{
}} "& .MuiSelect-select": {
> display: "flex",
<MenuItem value={false}> alignItems: "center",
<ListItemIcon><Check/></ListItemIcon> paddingTop: "4px",
<ListItemText primary={t("reservation_delete_dialog_action_keep_title")}/> paddingBottom: "4px",
</MenuItem> },
<MenuItem value={true}> }}
<ListItemIcon><DeleteForever/></ListItemIcon> >
<ListItemText primary={t("reservation_delete_dialog_action_delete_title")}/> <MenuItem value={false}>
</MenuItem> <ListItemIcon>
</Select> <Check />
</FormControl> </ListItemIcon>
{!deleteMessages && <ListItemText
<Alert severity="info" sx={{ mt: 1 }}> primary={t("reservation_delete_dialog_action_keep_title")}
{t("reservation_delete_dialog_action_keep_description")} />
</Alert> </MenuItem>
} <MenuItem value={true}>
{deleteMessages && <ListItemIcon>
<Alert severity="warning" sx={{ mt: 1 }}> <DeleteForever />
{t("reservation_delete_dialog_action_delete_description")} </ListItemIcon>
</Alert> <ListItemText
} primary={t("reservation_delete_dialog_action_delete_title")}
</DialogContent> />
<DialogFooter status={error}> </MenuItem>
<Button onClick={props.onClose}>{t("common_cancel")}</Button> </Select>
<Button onClick={handleSubmit} color="error">{t("reservation_delete_dialog_submit_button")}</Button> </FormControl>
</DialogFooter> {!deleteMessages && (
</Dialog> <Alert severity="info" sx={{ mt: 1 }}>
); {t("reservation_delete_dialog_action_keep_description")}
</Alert>
)}
{deleteMessages && (
<Alert severity="warning" sx={{ mt: 1 }}>
{t("reservation_delete_dialog_action_delete_description")}
</Alert>
)}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} color="error">
{t("reservation_delete_dialog_submit_button")}
</Button>
</DialogFooter>
</Dialog>
);
}; };

View file

@ -1,46 +1,55 @@
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}
<Box style={{
sx={{ position: "relative",
position: "absolute", display: "inline-flex",
right: "-6px", verticalAlign: "middle",
bottom: "5px", height: "24px",
fontSize: 10, }}
fontWeight: 600, >
color: "gray", <Icon fontSize={size} sx={{ color: "gray" }} />
width: "8px", {props.text && (
height: "8px", <Box
marginTop: "3px" sx={{
}} position: "absolute",
> right: "-6px",
{props.text} bottom: "5px",
</Box> fontSize: 10,
} fontWeight: 600,
color: "gray",
width: "8px",
height: "8px",
marginTop: "3px",
}}
>
{props.text}
</Box> </Box>
); )}
</Box>
);
}); });

View file

@ -1,49 +1,70 @@
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();
const sx = props.sx || {}; const sx = props.sx || {};
return ( return (
<FormControl fullWidth variant="standard" sx={sx}> <FormControl fullWidth variant="standard" sx={sx}>
<Select <Select
value={props.value} value={props.value}
onChange={(ev) => props.onChange(ev.target.value)} onChange={(ev) => props.onChange(ev.target.value)}
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 />
</MenuItem> </ListItemIcon>
<MenuItem value={Permission.READ_ONLY}> <ListItemText
<ListItemIcon><PermissionRead/></ListItemIcon> primary={t("prefs_reservations_table_everyone_deny_all")}
<ListItemText primary={t("prefs_reservations_table_everyone_read_only")}/> />
</MenuItem> </MenuItem>
<MenuItem value={Permission.WRITE_ONLY}> <MenuItem value={Permission.READ_ONLY}>
<ListItemIcon><PermissionWrite/></ListItemIcon> <ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_write_only")}/> <PermissionRead />
</MenuItem> </ListItemIcon>
<MenuItem value={Permission.READ_WRITE}> <ListItemText
<ListItemIcon><PermissionReadWrite/></ListItemIcon> primary={t("prefs_reservations_table_everyone_read_only")}
<ListItemText primary={t("prefs_reservations_table_everyone_read_write")}/> />
</MenuItem> </MenuItem>
</Select> <MenuItem value={Permission.WRITE_ONLY}>
</FormControl> <ListItemIcon>
); <PermissionWrite />
</ListItemIcon>
<ListItemText
primary={t("prefs_reservations_table_everyone_write_only")}
/>
</MenuItem>
<MenuItem value={Permission.READ_WRITE}>
<ListItemIcon>
<PermissionReadWrite />
</ListItemIcon>
<ListItemText
primary={t("prefs_reservations_table_everyone_read_write")}
/>
</MenuItem>
</Select>
</FormControl>
);
}; };
export default ReserveTopicSelect; export default ReserveTopicSelect;

View file

@ -1,158 +1,167 @@
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();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState(""); const [confirm, setConfirm] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
const handleSubmit = async (event) => { const handleSubmit = async (event) => {
event.preventDefault(); event.preventDefault();
const user = { username, password }; const user = { username, password };
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(
session.store(user.username, token); `[Signup] User signup for user ${user.username} successful, token is ${token}`
window.location.href = routes.app; );
} catch (e) { session.store(user.username, token);
console.log(`[Signup] Signup for user ${user.username} failed`, e); window.location.href = routes.app;
if (e instanceof UserExistsError) { } catch (e) {
setError(t("signup_error_username_taken", { username: e.username })); console.log(`[Signup] Signup for user ${user.username} failed`, e);
} else if ((e instanceof AccountCreateLimitReachedError)) { if (e instanceof UserExistsError) {
setError(t("signup_error_creation_limit_reached")); setError(t("signup_error_username_taken", { username: e.username }));
} else { } else if (e instanceof AccountCreateLimitReachedError) {
setError(e.message); setError(t("signup_error_creation_limit_reached"));
} } else {
} setError(e.message);
}; }
if (!config.enable_signup) {
return (
<AvatarBox>
<Typography sx={{ typography: 'h6' }}>{t("signup_disabled")}</Typography>
</AvatarBox>
);
} }
};
if (!config.enable_signup) {
return ( return (
<AvatarBox> <AvatarBox>
<Typography sx={{ typography: 'h6' }}> <Typography sx={{ typography: "h6" }}>
{t("signup_title")} {t("signup_disabled")}
</Typography> </Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}> </AvatarBox>
<TextField
margin="dense"
required
fullWidth
id="username"
label={t("signup_form_username")}
name="username"
value={username}
onChange={ev => setUsername(ev.target.value.trim())}
autoFocus
/>
<TextField
margin="dense"
required
fullWidth
name="password"
label={t("signup_form_password")}
type={showPassword ? "text" : "password"}
id="password"
autoComplete="new-password"
value={password}
onChange={ev => setPassword(ev.target.value.trim())}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t("signup_form_toggle_password_visibility")}
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(ev) => ev.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
<TextField
margin="dense"
required
fullWidth
name="password"
label={t("signup_form_confirm_password")}
type={showConfirm ? "text" : "password"}
id="confirm"
autoComplete="new-password"
value={confirm}
onChange={ev => setConfirm(ev.target.value.trim())}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t("signup_form_toggle_password_visibility")}
onClick={() => setShowConfirm(!showConfirm)}
onMouseDown={(ev) => ev.preventDefault()}
edge="end"
>
{showConfirm ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
disabled={username === "" || password === "" || password !== confirm}
sx={{mt: 2, mb: 2}}
>
{t("signup_form_button_submit")}
</Button>
{error &&
<Box sx={{
mb: 1,
display: 'flex',
flexGrow: 1,
justifyContent: 'center',
}}>
<WarningAmberIcon color="error" sx={{mr: 1}}/>
<Typography sx={{color: 'error.main'}}>{error}</Typography>
</Box>
}
</Box>
{config.enable_login &&
<Typography sx={{mb: 4}}>
<NavLink to={routes.login} variant="body1">
{t("signup_already_have_account")}
</NavLink>
</Typography>
}
</AvatarBox>
); );
} }
return (
<AvatarBox>
<Typography sx={{ typography: "h6" }}>{t("signup_title")}</Typography>
<Box
component="form"
onSubmit={handleSubmit}
noValidate
sx={{ mt: 1, maxWidth: 400 }}
>
<TextField
margin="dense"
required
fullWidth
id="username"
label={t("signup_form_username")}
name="username"
value={username}
onChange={(ev) => setUsername(ev.target.value.trim())}
autoFocus
/>
<TextField
margin="dense"
required
fullWidth
name="password"
label={t("signup_form_password")}
type={showPassword ? "text" : "password"}
id="password"
autoComplete="new-password"
value={password}
onChange={(ev) => setPassword(ev.target.value.trim())}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t("signup_form_toggle_password_visibility")}
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(ev) => ev.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<TextField
margin="dense"
required
fullWidth
name="password"
label={t("signup_form_confirm_password")}
type={showConfirm ? "text" : "password"}
id="confirm"
autoComplete="new-password"
value={confirm}
onChange={(ev) => setConfirm(ev.target.value.trim())}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t("signup_form_toggle_password_visibility")}
onClick={() => setShowConfirm(!showConfirm)}
onMouseDown={(ev) => ev.preventDefault()}
edge="end"
>
{showConfirm ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
disabled={username === "" || password === "" || password !== confirm}
sx={{ mt: 2, mb: 2 }}
>
{t("signup_form_button_submit")}
</Button>
{error && (
<Box
sx={{
mb: 1,
display: "flex",
flexGrow: 1,
justifyContent: "center",
}}
>
<WarningAmberIcon color="error" sx={{ mr: 1 }} />
<Typography sx={{ color: "error.main" }}>{error}</Typography>
</Box>
)}
</Box>
{config.enable_login && (
<Typography sx={{ mb: 4 }}>
<NavLink to={routes.login} variant="body1">
{t("signup_already_have_account")}
</NavLink>
</Typography>
)}
</AvatarBox>
);
};
export default Signup; export default Signup;

View file

@ -1,313 +1,388 @@
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";
const SubscribeDialog = (props) => { 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 && (
baseUrl={baseUrl} <SubscribePage
setBaseUrl={setBaseUrl} baseUrl={baseUrl}
topic={topic} setBaseUrl={setBaseUrl}
setTopic={setTopic} topic={topic}
subscriptions={props.subscriptions} setTopic={setTopic}
onCancel={props.onCancel} subscriptions={props.subscriptions}
onNeedsLogin={() => setShowLoginPage(true)} onCancel={props.onCancel}
onSuccess={handleSuccess} onNeedsLogin={() => setShowLoginPage(true)}
/>} onSuccess={handleSuccess}
{showLoginPage && <LoginPage />
baseUrl={baseUrl} )}
topic={topic} {showLoginPage && (
onBack={() => setShowLoginPage(false)} <LoginPage
onSuccess={handleSuccess} baseUrl={baseUrl}
/>} topic={topic}
</Dialog> onBack={() => setShowLoginPage(false)}
); onSuccess={handleSuccess}
/>
)}
</Dialog>
);
}; };
const SubscribePage = (props) => { const SubscribePage = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
const [error, setError] = useState(""); const [error, setError] = useState("");
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(
if (user) { `[SubscribeDialog] Login to ${topicUrl(
setError(t("subscribe_dialog_error_user_not_authorized", { username: username })); baseUrl,
return; topic
} else { )} failed for user ${username}`
props.onNeedsLogin(); );
return; if (user) {
} setError(
t("subscribe_dialog_error_user_not_authorized", {
username: username,
})
);
return;
} else {
props.onNeedsLogin();
return;
}
}
// Reserve topic (if requested)
if (
session.exists() &&
baseUrl === config.base_url &&
reserveTopicVisible
) {
console.log(
`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`
);
try {
await accountApi.upsertReservation(topic, everyone);
} catch (e) {
console.log(`[SubscribeDialog] Error reserving topic`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else if (e instanceof TopicReservedError) {
setError(t("subscribe_dialog_error_topic_already_reserved"));
return;
} }
}
}
// Reserve topic (if requested) console.log(
if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) { `[SubscribeDialog] Successful login to ${topicUrl(
console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`); baseUrl,
try { topic
await accountApi.upsertReservation(topic, everyone); )} for user ${username}`
} catch (e) {
console.log(`[SubscribeDialog] Error reserving topic`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else if (e instanceof TopicReservedError) {
setError(t("subscribe_dialog_error_topic_already_reserved"));
return;
}
}
}
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
props.onSuccess();
};
const handleUseAnotherChanged = (e) => {
props.setBaseUrl("");
setAnotherServerVisible(e.target.checked);
};
const subscribeButtonEnabled = (() => {
if (anotherServerVisible) {
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
} else {
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic));
return validTopic(topic) && !isExistingTopicUrl;
}
})();
const updateBaseUrl = (ev, newVal) => {
if (validUrl(newVal)) {
props.setBaseUrl(newVal.replace(/\/$/, '')); // strip trailing slash after https?://
} else {
props.setBaseUrl(newVal);
}
};
return (
<>
<DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("subscribe_dialog_subscribe_description")}
</DialogContentText>
<div style={{display: 'flex', paddingBottom: "8px"}} role="row">
<TextField
autoFocus
margin="dense"
id="topic"
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
value={props.topic}
onChange={ev => props.setTopic(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
maxLength: 64,
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder")
}}
/>
<Button onClick={() => {props.setTopic(randomAlphanumericString(16))}} style={{flexShrink: "0", marginTop: "0.5em"}}>
{t("subscribe_dialog_subscribe_button_generate_topic_name")}
</Button>
</div>
{showReserveTopicCheckbox &&
<FormGroup>
<FormControlLabel
variant="standard"
control={
<Checkbox
fullWidth
disabled={!reserveTopicEnabled}
checked={reserveTopicVisible}
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
inputProps={{
"aria-label": t("reserve_dialog_checkbox_label")
}}
/>
}
label={
<>
{t("reserve_dialog_checkbox_label")}
<ReserveLimitChip/>
</>
}
/>
{reserveTopicVisible &&
<ReserveTopicSelect
value={everyone}
onChange={setEveryone}
/>
}
</FormGroup>
}
{!reserveTopicVisible &&
<FormGroup>
<FormControlLabel
control={
<Checkbox
onChange={handleUseAnotherChanged}
inputProps={{
"aria-label": t("subscribe_dialog_subscribe_use_another_label")
}}
/>
}
label={t("subscribe_dialog_subscribe_use_another_label")}/>
{anotherServerVisible && <Autocomplete
freeSolo
options={existingBaseUrls}
inputValue={props.baseUrl}
onInputChange={updateBaseUrl}
renderInput={(params) =>
<TextField
{...params}
placeholder={config.base_url}
variant="standard"
aria-label={t("subscribe_dialog_subscribe_base_url_label")}
/>
}
/>}
</FormGroup>
}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>{t("subscribe_dialog_subscribe_button_subscribe")}</Button>
</DialogFooter>
</>
); );
props.onSuccess();
};
const handleUseAnotherChanged = (e) => {
props.setBaseUrl("");
setAnotherServerVisible(e.target.checked);
};
const subscribeButtonEnabled = (() => {
if (anotherServerVisible) {
const isExistingTopicUrl = existingTopicUrls.includes(
topicUrl(baseUrl, topic)
);
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
} else {
const isExistingTopicUrl = existingTopicUrls.includes(
topicUrl(config.base_url, topic)
);
return validTopic(topic) && !isExistingTopicUrl;
}
})();
const updateBaseUrl = (ev, newVal) => {
if (validUrl(newVal)) {
props.setBaseUrl(newVal.replace(/\/$/, "")); // strip trailing slash after https?://
} else {
props.setBaseUrl(newVal);
}
};
return (
<>
<DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("subscribe_dialog_subscribe_description")}
</DialogContentText>
<div style={{ display: "flex", paddingBottom: "8px" }} role="row">
<TextField
autoFocus
margin="dense"
id="topic"
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
value={props.topic}
onChange={(ev) => props.setTopic(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
maxLength: 64,
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder"),
}}
/>
<Button
onClick={() => {
props.setTopic(randomAlphanumericString(16));
}}
style={{ flexShrink: "0", marginTop: "0.5em" }}
>
{t("subscribe_dialog_subscribe_button_generate_topic_name")}
</Button>
</div>
{showReserveTopicCheckbox && (
<FormGroup>
<FormControlLabel
variant="standard"
control={
<Checkbox
fullWidth
disabled={!reserveTopicEnabled}
checked={reserveTopicVisible}
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
inputProps={{
"aria-label": t("reserve_dialog_checkbox_label"),
}}
/>
}
label={
<>
{t("reserve_dialog_checkbox_label")}
<ReserveLimitChip />
</>
}
/>
{reserveTopicVisible && (
<ReserveTopicSelect value={everyone} onChange={setEveryone} />
)}
</FormGroup>
)}
{!reserveTopicVisible && (
<FormGroup>
<FormControlLabel
control={
<Checkbox
onChange={handleUseAnotherChanged}
inputProps={{
"aria-label": t(
"subscribe_dialog_subscribe_use_another_label"
),
}}
/>
}
label={t("subscribe_dialog_subscribe_use_another_label")}
/>
{anotherServerVisible && (
<Autocomplete
freeSolo
options={existingBaseUrls}
inputValue={props.baseUrl}
onInputChange={updateBaseUrl}
renderInput={(params) => (
<TextField
{...params}
placeholder={config.base_url}
variant="standard"
aria-label={t("subscribe_dialog_subscribe_base_url_label")}
/>
)}
/>
)}
</FormGroup>
)}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onCancel}>
{t("subscribe_dialog_subscribe_button_cancel")}
</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>
{t("subscribe_dialog_subscribe_button_subscribe")}
</Button>
</DialogFooter>
</>
);
}; };
const LoginPage = (props) => { const LoginPage = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
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(
return; baseUrl,
} topic
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); )} failed for user ${username}`
await userManager.save(user); );
props.onSuccess(); setError(
}; t("subscribe_dialog_error_user_not_authorized", { username: username })
);
return ( return;
<> }
<DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle> console.log(
<DialogContent> `[SubscribeDialog] Successful login to ${topicUrl(
<DialogContentText> baseUrl,
{t("subscribe_dialog_login_description")} topic
</DialogContentText> )} for user ${username}`
<TextField
autoFocus
margin="dense"
id="username"
label={t("subscribe_dialog_login_username_label")}
value={username}
onChange={ev => setUsername(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_username_label")
}}
/>
<TextField
margin="dense"
id="password"
label={t("subscribe_dialog_login_password_label")}
type="password"
value={password}
onChange={ev => setPassword(ev.target.value)}
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_password_label")
}}
/>
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onBack}>{t("common_back")}</Button>
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
</DialogFooter>
</>
); );
await userManager.save(user);
props.onSuccess();
};
return (
<>
<DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("subscribe_dialog_login_description")}
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="username"
label={t("subscribe_dialog_login_username_label")}
value={username}
onChange={(ev) => setUsername(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_username_label"),
}}
/>
<TextField
margin="dense"
id="password"
label={t("subscribe_dialog_login_password_label")}
type="password"
value={password}
onChange={(ev) => setPassword(ev.target.value)}
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_password_label"),
}}
/>
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onBack}>{t("common_back")}</Button>
<Button onClick={handleLogin}>
{t("subscribe_dialog_login_button_login")}
</Button>
</DialogFooter>
</>
);
}; };
export const subscribeTopic = async (baseUrl, topic) => { export const subscribeTopic = async (baseUrl, topic) => {
const subscription = await subscriptionManager.add(baseUrl, topic); const subscription = await subscriptionManager.add(baseUrl, topic);
if (session.exists()) { if (session.exists()) {
try { try {
await accountApi.addSubscription(baseUrl, topic); await accountApi.addSubscription(baseUrl, topic);
} catch (e) { } catch (e) {
console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} }
}
} }
return subscription; }
return subscription;
}; };
export default SubscribeDialog; export default SubscribeDialog;

View file

@ -1,292 +1,393 @@
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();
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
const navigate = useNavigate(); const navigate = useNavigate();
const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false); const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false);
const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false); const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false);
const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false); const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false);
const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false); const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false);
const [showPublishError, setShowPublishError] = useState(false); const [showPublishError, setShowPublishError] = useState(false);
const subscription = props.subscription; const subscription = props.subscription;
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 () => {
setReserveAddDialogOpen(true);
};
const handleReserveEdit = async () => {
setReserveEditDialogOpen(true);
};
const handleReserveDelete = async () => {
setReserveDeleteDialogOpen(true);
};
const handleSendTestMessage = async () => {
const baseUrl = props.subscription.baseUrl;
const topic = props.subscription.topic;
const tags = shuffle([
"grinning",
"octopus",
"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 title = shuffle([
"",
"",
"", // Higher chance of no title
"Oh my, another test message?",
"Titles are optional, did you know that?",
"ntfy is open source, and will always be free. Cool, right?",
"I don't really like apples",
"My favorite TV show is The Wire. You should watch it!",
"You can attach files and URLs to messages too",
"You can delay messages up to 3 days",
])[0];
const nowSeconds = Math.round(Date.now() / 1000);
const message = shuffle([
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(
nowSeconds
)} right now. Is that early or late?`,
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
`Alright then, it's ${formatShortDateTime(
nowSeconds
)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`,
])[0];
try {
await api.publish(baseUrl, topic, message, {
title: title,
priority: priority,
tags: tags,
});
} catch (e) {
console.log(`[SubscriptionPopup] Error publishing message`, e);
setShowPublishError(true);
} }
};
const handleReserveAdd = async () => { const handleClearAll = async () => {
setReserveAddDialogOpen(true); console.log(
} `[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`
const handleReserveEdit = async () => {
setReserveEditDialogOpen(true);
}
const handleReserveDelete = async () => {
setReserveDeleteDialogOpen(true);
}
const handleSendTestMessage = async () => {
const baseUrl = props.subscription.baseUrl;
const topic = props.subscription.topic;
const tags = shuffle([
"grinning", "octopus", "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 title = shuffle([
"",
"",
"", // Higher chance of no title
"Oh my, another test message?",
"Titles are optional, did you know that?",
"ntfy is open source, and will always be free. Cool, right?",
"I don't really like apples",
"My favorite TV show is The Wire. You should watch it!",
"You can attach files and URLs to messages too",
"You can delay messages up to 3 days"
])[0];
const nowSeconds = Math.round(Date.now()/1000);
const message = shuffle([
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
`Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`
])[0];
try {
await api.publish(baseUrl, topic, message, {
title: title,
priority: priority,
tags: tags
});
} catch (e) {
console.log(`[SubscriptionPopup] Error publishing message`, e);
setShowPublishError(true);
}
}
const handleClearAll = async () => {
console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`);
await subscriptionManager.deleteNotifications(props.subscription.id);
};
const handleUnsubscribe = async () => {
console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription);
await subscriptionManager.remove(props.subscription.id);
if (session.exists() && !subscription.internal) {
try {
await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic);
} catch (e) {
console.log(`[SubscriptionPopup] Error unsubscribing`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
const newSelected = await subscriptionManager.first(); // May be undefined
if (newSelected && !newSelected.internal) {
navigate(routes.forSubscription(newSelected));
} else {
navigate(routes.app);
}
};
return (
<>
<PopupMenu
horizontal={placement}
anchorEl={props.anchor}
open={!!props.anchor}
onClose={props.onClose}
>
<MenuItem onClick={handleChangeDisplayName}>{t("action_bar_change_display_name")}</MenuItem>
{showReservationAdd && <MenuItem onClick={handleReserveAdd}>{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>
</PopupMenu>
<Portal>
<Snackbar
open={showPublishError}
autoHideDuration={3000}
onClose={() => setShowPublishError(false)}
message={t("message_bar_error_publishing")}
/>
<DisplayNameDialog
open={displayNameDialogOpen}
subscription={subscription}
onClose={() => setDisplayNameDialogOpen(false)}
/>
{showReservationAdd &&
<ReserveAddDialog
open={reserveAddDialogOpen}
topic={subscription.topic}
reservations={reservations}
onClose={() => setReserveAddDialogOpen(false)}
/>
}
{showReservationEdit &&
<ReserveEditDialog
open={reserveEditDialogOpen}
reservation={subscription.reservation}
reservations={props.reservations}
onClose={() => setReserveEditDialogOpen(false)}
/>
}
{showReservationDelete &&
<ReserveDeleteDialog
open={reserveDeleteDialogOpen}
topic={subscription.topic}
onClose={() => setReserveDeleteDialogOpen(false)}
/>
}
</Portal>
</>
); );
await subscriptionManager.deleteNotifications(props.subscription.id);
};
const handleUnsubscribe = async () => {
console.log(
`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`,
props.subscription
);
await subscriptionManager.remove(props.subscription.id);
if (session.exists() && !subscription.internal) {
try {
await accountApi.deleteSubscription(
props.subscription.baseUrl,
props.subscription.topic
);
} catch (e) {
console.log(`[SubscriptionPopup] Error unsubscribing`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
const newSelected = await subscriptionManager.first(); // May be undefined
if (newSelected && !newSelected.internal) {
navigate(routes.forSubscription(newSelected));
} else {
navigate(routes.app);
}
};
return (
<>
<PopupMenu
horizontal={placement}
anchorEl={props.anchor}
open={!!props.anchor}
onClose={props.onClose}
>
<MenuItem onClick={handleChangeDisplayName}>
{t("action_bar_change_display_name")}
</MenuItem>
{showReservationAdd && (
<MenuItem onClick={handleReserveAdd}>
{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>
</PopupMenu>
<Portal>
<Snackbar
open={showPublishError}
autoHideDuration={3000}
onClose={() => setShowPublishError(false)}
message={t("message_bar_error_publishing")}
/>
<DisplayNameDialog
open={displayNameDialogOpen}
subscription={subscription}
onClose={() => setDisplayNameDialogOpen(false)}
/>
{showReservationAdd && (
<ReserveAddDialog
open={reserveAddDialogOpen}
topic={subscription.topic}
reservations={reservations}
onClose={() => setReserveAddDialogOpen(false)}
/>
)}
{showReservationEdit && (
<ReserveEditDialog
open={reserveEditDialogOpen}
reservation={subscription.reservation}
reservations={props.reservations}
onClose={() => setReserveEditDialogOpen(false)}
/>
)}
{showReservationDelete && (
<ReserveDeleteDialog
open={reserveDeleteDialogOpen}
topic={subscription.topic}
onClose={() => setReserveDeleteDialogOpen(false)}
/>
)}
</Portal>
</>
);
}; };
const DisplayNameDialog = (props) => { 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}`
} catch (e) { );
console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e); await accountApi.updateSubscription(
if (e instanceof UnauthorizedError) { subscription.baseUrl,
session.resetAndRedirect(routes.login); subscription.topic,
} else { { display_name: displayName }
setError(e.message); );
return; } catch (e) {
} console.log(
} `[SubscriptionSettingsDialog] Error updating subscription`,
e
);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
} }
props.onClose(); }
} }
props.onClose();
};
return ( return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <Dialog
<DialogTitle>{t("display_name_dialog_title")}</DialogTitle> open={props.open}
<DialogContent> onClose={props.onClose}
<DialogContentText> maxWidth="sm"
{t("display_name_dialog_description")} fullWidth
</DialogContentText> fullScreen={fullScreen}
<TextField >
autoFocus <DialogTitle>{t("display_name_dialog_title")}</DialogTitle>
placeholder={t("display_name_dialog_placeholder")} <DialogContent>
value={displayName} <DialogContentText>
onChange={ev => setDisplayName(ev.target.value)} {t("display_name_dialog_description")}
type="text" </DialogContentText>
fullWidth <TextField
variant="standard" autoFocus
inputProps={{ placeholder={t("display_name_dialog_placeholder")}
maxLength: 64, value={displayName}
"aria-label": t("display_name_dialog_placeholder") onChange={(ev) => setDisplayName(ev.target.value)}
}} type="text"
InputProps={{ fullWidth
endAdornment: ( variant="standard"
<InputAdornment position="end"> inputProps={{
<IconButton onClick={() => setDisplayName("")} edge="end"> maxLength: 64,
<Clear/> "aria-label": t("display_name_dialog_placeholder"),
</IconButton> }}
</InputAdornment> InputProps={{
) endAdornment: (
}} <InputAdornment position="end">
/> <IconButton onClick={() => setDisplayName("")} edge="end">
</DialogContent> <Clear />
<DialogFooter status={error}> </IconButton>
<Button onClick={props.onClose}>{t("common_cancel")}</Button> </InputAdornment>
<Button onClick={handleSave}>{t("common_save")}</Button> ),
</DialogFooter> }}
</Dialog> />
); </DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSave}>{t("common_save")}</Button>
</DialogFooter>
</Dialog>
);
}; };
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 (
return <></>; account?.role === Role.ADMIN ||
} else if (config.enable_payments) { account?.stats.reservations_remaining > 0
return (account?.limits.reservations > 0) ? <LimitReachedChip/> : <ProChip/>; ) {
} else if (account) {
return <LimitReachedChip/>;
}
return <></>; return <></>;
} else if (config.enable_payments) {
return account?.limits.reservations > 0 ? (
<LimitReachedChip />
) : (
<ProChip />
);
} else if (account) {
return <LimitReachedChip />;
}
return <></>;
}; };
const LimitReachedChip = () => { const LimitReachedChip = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Chip <Chip
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",
}}
/>
);
}; };
export const ProChip = () => { export const ProChip = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Chip <Chip
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,367 +1,500 @@
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";
const UpgradeDialog = (props) => { const UpgradeDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
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(
const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined account?.billing?.interval || SubscriptionInterval.YEAR
const [loading, setLoading] = useState(false); );
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined
const [loading, setLoading] = useState(false);
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
}, []); }, []);
if (!tiers) { if (!tiers) {
return <></>; return <></>;
}
const tiersMap = Object.assign(
...tiers.map((tier) => ({ [tier.code]: tier }))
);
const newTier = tiersMap[newTierCode]; // May be undefined
const currentTier = account?.tier; // May be undefined
const currentInterval = account?.billing?.interval; // May be undefined
const currentTierCode = currentTier?.code; // May be undefined
// Figure out buttons, labels and the submit action
let submitAction, submitButtonLabel, banner;
if (!account) {
submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
submitAction = Action.REDIRECT_SIGNUP;
banner = null;
} else if (
currentTierCode === newTierCode &&
(currentInterval === undefined || currentInterval === interval)
) {
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
submitAction = null;
banner = currentTierCode ? Banner.PRORATION_INFO : null;
} else if (!currentTierCode) {
submitButtonLabel = t("account_upgrade_dialog_button_pay_now");
submitAction = Action.CREATE_SUBSCRIPTION;
banner = null;
} else if (!newTierCode) {
submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription");
submitAction = Action.CANCEL_SUBSCRIPTION;
banner = Banner.CANCEL_WARNING;
} else {
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
submitAction = Action.UPDATE_SUBSCRIPTION;
banner = Banner.PRORATION_INFO;
}
// Exceptional conditions
if (loading) {
submitAction = null;
} else if (
newTier?.code &&
account?.reservations?.length > newTier?.limits?.reservations
) {
submitAction = null;
banner = Banner.RESERVATIONS_WARNING;
}
const handleSubmit = async () => {
if (submitAction === Action.REDIRECT_SIGNUP) {
window.location.href = routes.signup;
return;
} }
try {
const tiersMap = Object.assign(...tiers.map(tier => ({[tier.code]: tier}))); setLoading(true);
const newTier = tiersMap[newTierCode]; // May be undefined if (submitAction === Action.CREATE_SUBSCRIPTION) {
const currentTier = account?.tier; // May be undefined const response = await accountApi.createBillingSubscription(
const currentInterval = account?.billing?.interval; // May be undefined newTierCode,
const currentTierCode = currentTier?.code; // May be undefined interval
);
// Figure out buttons, labels and the submit action window.location.href = response.redirect_url;
let submitAction, submitButtonLabel, banner; } else if (submitAction === Action.UPDATE_SUBSCRIPTION) {
if (!account) { await accountApi.updateBillingSubscription(newTierCode, interval);
submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup"); } else if (submitAction === Action.CANCEL_SUBSCRIPTION) {
submitAction = Action.REDIRECT_SIGNUP; await accountApi.deleteBillingSubscription();
banner = null; }
} else if (currentTierCode === newTierCode && (currentInterval === undefined || currentInterval === interval)) { props.onCancel();
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); } catch (e) {
submitAction = null; console.log(`[UpgradeDialog] Error changing billing subscription`, e);
banner = (currentTierCode) ? Banner.PRORATION_INFO : null; if (e instanceof UnauthorizedError) {
} else if (!currentTierCode) { session.resetAndRedirect(routes.login);
submitButtonLabel = t("account_upgrade_dialog_button_pay_now"); } else {
submitAction = Action.CREATE_SUBSCRIPTION; setError(e.message);
banner = null; }
} else if (!newTierCode) { } finally {
submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription"); setLoading(false);
submitAction = Action.CANCEL_SUBSCRIPTION;
banner = Banner.CANCEL_WARNING;
} else {
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
submitAction = Action.UPDATE_SUBSCRIPTION;
banner = Banner.PRORATION_INFO;
} }
};
// Exceptional conditions // Figure out discount
if (loading) { let discount = 0,
submitAction = null; upto = false;
} else if (newTier?.code && account?.reservations?.length > newTier?.limits?.reservations) { if (newTier?.prices) {
submitAction = null; discount = Math.round(
banner = Banner.RESERVATIONS_WARNING; ((newTier.prices.month * 12) / newTier.prices.year - 1) * 100
}
const handleSubmit = async () => {
if (submitAction === Action.REDIRECT_SIGNUP) {
window.location.href = routes.signup;
return;
}
try {
setLoading(true);
if (submitAction === Action.CREATE_SUBSCRIPTION) {
const response = await accountApi.createBillingSubscription(newTierCode, interval);
window.location.href = response.redirect_url;
} else if (submitAction === Action.UPDATE_SUBSCRIPTION) {
await accountApi.updateBillingSubscription(newTierCode, interval);
} else if (submitAction === Action.CANCEL_SUBSCRIPTION) {
await accountApi.deleteBillingSubscription();
}
props.onCancel();
} catch (e) {
console.log(`[UpgradeDialog] Error changing billing subscription`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
}
} finally {
setLoading(false);
}
}
// Figure out discount
let discount = 0, upto = false;
if (newTier?.prices) {
discount = Math.round(((newTier.prices.month*12/newTier.prices.year)-1)*100);
} else {
let n = 0;
for (const t of tiers) {
if (t.prices) {
const tierDiscount = Math.round(((t.prices.month*12/t.prices.year)-1)*100);
if (tierDiscount > discount) {
discount = tierDiscount;
n++;
}
}
}
upto = n > 1;
}
return (
<Dialog
open={props.open}
onClose={props.onCancel}
maxWidth="lg"
fullScreen={fullScreen}
>
<DialogTitle>
<div style={{ display: "flex", flexDirection: "row" }}>
<div style={{ flexGrow: 1 }}>{t("account_upgrade_dialog_title")}</div>
<div style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
marginTop: "4px"
}}>
<Typography component="span" variant="subtitle1">{t("account_upgrade_dialog_interval_monthly")}</Typography>
<Switch
checked={interval === SubscriptionInterval.YEAR}
onChange={(ev) => setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)}
/>
<Typography component="span" variant="subtitle1">{t("account_upgrade_dialog_interval_yearly")}</Typography>
{discount > 0 &&
<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 })}
color="primary"
size="small"
variant={interval === SubscriptionInterval.YEAR ? "filled" : "outlined"}
sx={{ marginLeft: "5px" }}
/>
}
</div>
</div>
</DialogTitle>
<DialogContent>
<div style={{
display: "flex",
flexDirection: "row",
marginBottom: "8px",
width: "100%"
}}>
{tiers.map(tier =>
<TierCard
key={`tierCard${tier.code || '_free'}`}
tier={tier}
current={currentTierCode === tier.code} // tier.code or currentTierCode may be undefined!
selected={newTierCode === tier.code} // tier.code may be undefined!
interval={interval}
onClick={() => setNewTierCode(tier.code)} // tier.code may be undefined!
/>
)}
</div>
{banner === Banner.CANCEL_WARNING &&
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
<Trans
i18nKey="account_upgrade_dialog_cancel_warning"
values={{ date: formatShortDate(account?.billing?.paid_until || 0) }} />
</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>
<Box sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
paddingLeft: '24px',
paddingBottom: '8px',
}}>
<DialogContentText
component="div"
aria-live="polite"
sx={{
margin: '0px',
paddingTop: '12px',
paddingBottom: '4px'
}}
>
{config.billing_contact.indexOf('@') !== -1 &&
<><Trans i18nKey="account_upgrade_dialog_billing_contact_email" 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}
</DialogContentText>
<DialogActions sx={{paddingRight: 2}}>
<Button onClick={props.onCancel}>{t("account_upgrade_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!submitAction}>{submitButtonLabel}</Button>
</DialogActions>
</Box>
</Dialog>
); );
} else {
let n = 0;
for (const t of tiers) {
if (t.prices) {
const tierDiscount = Math.round(
((t.prices.month * 12) / t.prices.year - 1) * 100
);
if (tierDiscount > discount) {
discount = tierDiscount;
n++;
}
}
}
upto = n > 1;
}
return (
<Dialog
open={props.open}
onClose={props.onCancel}
maxWidth="lg"
fullScreen={fullScreen}
>
<DialogTitle>
<div style={{ display: "flex", flexDirection: "row" }}>
<div style={{ flexGrow: 1 }}>{t("account_upgrade_dialog_title")}</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
marginTop: "4px",
}}
>
<Typography component="span" variant="subtitle1">
{t("account_upgrade_dialog_interval_monthly")}
</Typography>
<Switch
checked={interval === SubscriptionInterval.YEAR}
onChange={(ev) =>
setInterval(
ev.target.checked
? SubscriptionInterval.YEAR
: SubscriptionInterval.MONTH
)
}
/>
<Typography component="span" variant="subtitle1">
{t("account_upgrade_dialog_interval_yearly")}
</Typography>
{discount > 0 && (
<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 }
)
}
color="primary"
size="small"
variant={
interval === SubscriptionInterval.YEAR ? "filled" : "outlined"
}
sx={{ marginLeft: "5px" }}
/>
)}
</div>
</div>
</DialogTitle>
<DialogContent>
<div
style={{
display: "flex",
flexDirection: "row",
marginBottom: "8px",
width: "100%",
}}
>
{tiers.map((tier) => (
<TierCard
key={`tierCard${tier.code || "_free"}`}
tier={tier}
current={currentTierCode === tier.code} // tier.code or currentTierCode may be undefined!
selected={newTierCode === tier.code} // tier.code may be undefined!
interval={interval}
onClick={() => setNewTierCode(tier.code)} // tier.code may be undefined!
/>
))}
</div>
{banner === Banner.CANCEL_WARNING && (
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
<Trans
i18nKey="account_upgrade_dialog_cancel_warning"
values={{
date: formatShortDate(account?.billing?.paid_until || 0),
}}
/>
</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>
<Box
sx={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
paddingLeft: "24px",
paddingBottom: "8px",
}}
>
<DialogContentText
component="div"
aria-live="polite"
sx={{
margin: "0px",
paddingTop: "12px",
paddingBottom: "4px",
}}
>
{config.billing_contact.indexOf("@") !== -1 && (
<>
<Trans
i18nKey="account_upgrade_dialog_billing_contact_email"
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}
</DialogContentText>
<DialogActions sx={{ paddingRight: 2 }}>
<Button onClick={props.onCancel}>
{t("account_upgrade_dialog_button_cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!submitAction}>
{submitButtonLabel}
</Button>
</DialogActions>
</Box>
</Dialog>
);
}; };
const TierCard = (props) => { const TierCard = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const tier = props.tier; const tier = props.tier;
let cardStyle, labelStyle, labelText; let cardStyle, labelStyle, labelText;
if (props.selected) { if (props.selected) {
cardStyle = { background: "#eee", border: "3px solid #338574" }; cardStyle = { background: "#eee", border: "3px solid #338574" };
labelStyle = { background: "#338574", color: "white" }; labelStyle = { background: "#338574", color: "white" };
labelText = t("account_upgrade_dialog_tier_selected_label"); labelText = t("account_upgrade_dialog_tier_selected_label");
} else if (props.current) { } else if (props.current) {
cardStyle = { border: "3px solid #eee" }; cardStyle = { border: "3px solid #eee" };
labelStyle = { background: "#eee", color: "black" }; labelStyle = { background: "#eee", color: "black" };
labelText = t("account_upgrade_dialog_tier_current_label"); labelText = t("account_upgrade_dialog_tier_current_label");
} else { } else {
cardStyle = { border: "3px solid transparent" }; cardStyle = { border: "3px solid transparent" };
} }
let monthlyPrice; let monthlyPrice;
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
m: "7px", sx={{
minWidth: "240px", m: "7px",
flexGrow: 1, minWidth: "240px",
flexShrink: 1, flexGrow: 1,
flexBasis: 0, flexShrink: 1,
borderRadius: "5px", flexBasis: 0,
"&:first-of-type": { ml: 0 }, borderRadius: "5px",
"&:last-of-type": { mr: 0 }, "&:first-of-type": { ml: 0 },
...cardStyle "&:last-of-type": { mr: 0 },
}}> ...cardStyle,
<Card sx={{ height: "100%" }}> }}
<CardActionArea sx={{ height: "100%" }}> >
<CardContent onClick={props.onClick} sx={{ height: "100%" }}> <Card sx={{ height: "100%" }}>
{labelStyle && <CardActionArea sx={{ height: "100%" }}>
<div style={{ <CardContent onClick={props.onClick} sx={{ height: "100%" }}>
position: "absolute", {labelStyle && (
top: "0", <div
right: "15px", style={{
padding: "2px 10px", position: "absolute",
borderRadius: "3px", top: "0",
...labelStyle right: "15px",
}}>{labelText}</div> padding: "2px 10px",
} borderRadius: "3px",
<Typography variant="subtitle1" component="div"> ...labelStyle,
{tier.name || t("account_basics_tier_free")} }}
</Typography> >
<div> {labelText}
<Typography component="span" variant="h4" sx={{ fontWeight: 500, marginRight: "3px" }}>{formatPrice(monthlyPrice)}</Typography> </div>
{monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}</>} )}
</div> <Typography variant="subtitle1" component="div">
<List dense> {tier.name || t("account_basics_tier_free")}
{tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>} </Typography>
<Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature> <div>
<Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature> <Typography
{tier.limits.calls > 0 && <Feature>{t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}</Feature>} component="span"
<Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature> variant="h4"
{tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>} sx={{ fontWeight: 500, marginRight: "3px" }}
{tier.limits.calls === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_calls")}</NoFeature>} >
</List> {formatPrice(monthlyPrice)}
{tier.prices && props.interval === SubscriptionInterval.MONTH && </Typography>
<Typography variant="body2" color="gray"> {monthlyPrice > 0 && (
{t("account_upgrade_dialog_tier_price_billed_monthly", { price: formatPrice(tier.prices.month*12) })} <>/ {t("account_upgrade_dialog_tier_price_per_month")}</>
</Typography> )}
} </div>
{tier.prices && props.interval === SubscriptionInterval.YEAR && <List dense>
<Typography variant="body2" color="gray"> {tier.limits.reservations > 0 && (
{t("account_upgrade_dialog_tier_price_billed_yearly", { price: formatPrice(tier.prices.year), save: formatPrice(tier.prices.month*12-tier.prices.year) })} <Feature>
</Typography> {t("account_upgrade_dialog_tier_features_reservations", {
} reservations: tier.limits.reservations,
</CardContent> count: tier.limits.reservations,
</CardActionArea> })}
</Card> </Feature>
</Box> )}
<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>
{tier.prices && props.interval === SubscriptionInterval.MONTH && (
<Typography variant="body2" color="gray">
{t("account_upgrade_dialog_tier_price_billed_monthly", {
price: formatPrice(tier.prices.month * 12),
})}
</Typography>
)}
{tier.prices && props.interval === SubscriptionInterval.YEAR && (
<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),
})}
</Typography>
)}
</CardContent>
</CardActionArea>
</Card>
</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} </ListItem>
</Typography> );
}
/>
</ListItem>
);
}; };
const Action = { 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
@ -17,65 +17,82 @@ import {UnauthorizedError} from "../app/errors";
* to the connection being re-established). * to the connection being re-established).
*/ */
export const useConnectionListeners = (account, subscriptions, users) => { 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 subscription = await subscriptionManager.get(subscriptionId); const handleMessage = async (subscriptionId, message) => {
if (subscription.internal) { const subscription = await subscriptionManager.get(subscriptionId);
await handleInternalMessage(message); if (subscription.internal) {
} else { await handleInternalMessage(message);
await handleNotification(subscriptionId, message); } else {
} await handleNotification(subscriptionId, message);
};
const handleInternalMessage = async (message) => {
console.log(`[ConnectionListener] Received message on sync topic`, message.message);
try {
const data = JSON.parse(message.message);
if (data.event === "sync") {
console.log(`[ConnectionListener] Triggering account sync`);
await accountApi.sync();
} else {
console.log(`[ConnectionListener] Unknown message type. Doing nothing.`);
}
} catch (e) {
console.log(`[ConnectionListener] Error parsing sync topic message`, e);
}
};
const handleNotification = async (subscriptionId, notification) => {
const added = await subscriptionManager.addNotification(subscriptionId, notification);
if (added) {
const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription));
await notifier.notify(subscriptionId, notification, defaultClickAction)
}
};
connectionManager.registerStateListener(subscriptionManager.updateState);
connectionManager.registerMessageListener(handleMessage);
return () => {
connectionManager.resetStateListener();
connectionManager.resetMessageListener();
}
},
// We have to disable dep checking for "navigate". This is fine, it never changes.
// eslint-disable-next-line
[]
);
// Sync topic listener: For accounts with sync_topic, subscribe to an internal topic
useEffect(() => {
if (!account || !account.sync_topic) {
return;
} }
subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle! };
}, [account]);
// When subscriptions or users change, refresh the connections const handleInternalMessage = async (message) => {
useEffect(() => { console.log(
connectionManager.refresh(subscriptions, users); // Dangle `[ConnectionListener] Received message on sync topic`,
}, [subscriptions, users]); message.message
);
try {
const data = JSON.parse(message.message);
if (data.event === "sync") {
console.log(`[ConnectionListener] Triggering account sync`);
await accountApi.sync();
} else {
console.log(
`[ConnectionListener] Unknown message type. Doing nothing.`
);
}
} catch (e) {
console.log(
`[ConnectionListener] Error parsing sync topic message`,
e
);
}
};
const handleNotification = async (subscriptionId, notification) => {
const added = await subscriptionManager.addNotification(
subscriptionId,
notification
);
if (added) {
const defaultClickAction = (subscription) =>
navigate(routes.forSubscription(subscription));
await notifier.notify(
subscriptionId,
notification,
defaultClickAction
);
}
};
connectionManager.registerStateListener(subscriptionManager.updateState);
connectionManager.registerMessageListener(handleMessage);
return () => {
connectionManager.resetStateListener();
connectionManager.resetMessageListener();
};
},
// We have to disable dep checking for "navigate". This is fine, it never changes.
// eslint-disable-next-line
[]
);
// Sync topic listener: For accounts with sync_topic, subscribe to an internal topic
useEffect(() => {
if (!account || !account.sync_topic) {
return;
}
subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle!
}, [account]);
// When subscriptions or users change, refresh the connections
useEffect(() => {
connectionManager.refresh(subscriptions, users); // Dangle
}, [subscriptions, users]);
}; };
/** /**
@ -83,35 +100,43 @@ export const useConnectionListeners = (account, subscriptions, users) => {
* This will only be run once after the initial page load. * This will only be run once after the initial page load.
*/ */
export const useAutoSubscribe = (subscriptions, selected) => { export const useAutoSubscribe = (subscriptions, selected) => {
const [hasRun, setHasRun] = useState(false); const [hasRun, setHasRun] = useState(false);
const params = useParams(); const params = useParams();
useEffect(() => { useEffect(() => {
const loaded = subscriptions !== null && subscriptions !== undefined; const loaded = subscriptions !== null && subscriptions !== undefined;
if (!loaded || hasRun) { if (!loaded || hasRun) {
return; return;
}
setHasRun(true);
const eligible =
params.topic && !selected && !disallowedTopic(params.topic);
if (eligible) {
const baseUrl = params.baseUrl
? expandSecureUrl(params.baseUrl)
: config.base_url;
console.log(
`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`
);
(async () => {
const subscription = await subscriptionManager.add(
baseUrl,
params.topic
);
if (session.exists()) {
try {
await accountApi.addSubscription(baseUrl, params.topic);
} catch (e) {
console.log(`[Hooks] Auto-subscribing failed`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
} }
setHasRun(true); poller.pollInBackground(subscription); // Dangle!
const eligible = params.topic && !selected && !disallowedTopic(params.topic); })();
if (eligible) { }
const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : config.base_url; }, [params, subscriptions, selected, hasRun]);
console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
(async () => {
const subscription = await subscriptionManager.add(baseUrl, params.topic);
if (session.exists()) {
try {
await accountApi.addSubscription(baseUrl, params.topic);
} catch (e) {
console.log(`[Hooks] Auto-subscribing failed`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
poller.pollInBackground(subscription); // Dangle!
})();
}
}, [params, subscriptions, selected, hasRun]);
}; };
/** /**
@ -120,19 +145,19 @@ export const useAutoSubscribe = (subscriptions, selected) => {
* up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186. * up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186.
*/ */
export const useBackgroundProcesses = () => { export const useBackgroundProcesses = () => {
useEffect(() => { useEffect(() => {
poller.startWorker(); poller.startWorker();
pruner.startWorker(); pruner.startWorker();
accountApi.startWorker(); accountApi.startWorker();
}, []); }, []);
} };
export const useAccountListener = (setAccount) => { export const useAccountListener = (setAccount) => {
useEffect(() => { useEffect(() => {
accountApi.registerListener(setAccount); accountApi.registerListener(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
@ -12,18 +12,18 @@ import { initReactI18next } from 'react-i18next';
// https://github.com/i18next/react-i18next/tree/master/example/react // https://github.com/i18next/react-i18next/tree/master/example/react
i18n i18n
.use(Backend) .use(Backend)
.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,20 +1,20 @@
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",
signup: "/signup", signup: "/signup",
app: config.app_root, app: config.app_root,
account: "/account", account: "/account",
settings: "/settings", settings: "/settings",
subscription: "/:topic", subscription: "/:topic",
subscriptionExternal: "/:baseUrl/:topic", subscriptionExternal: "/:baseUrl/:topic",
forSubscription: (subscription) => { forSubscription: (subscription) => {
if (subscription.baseUrl !== config.base_url) { if (subscription.baseUrl !== config.base_url) {
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 />);