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.
var config = {
base_url: window.location.origin, // Change to test against a different server
app_root: "/app",
enable_login: true,
enable_signup: true,
enable_payments: false,
enable_reservations: true,
enable_emails: true,
enable_calls: true,
billing_contact: "",
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
base_url: window.location.origin, // Change to test against a different server
app_root: "/app",
enable_login: true,
enable_signup: true,
enable_payments: false,
enable_reservations: true,
enable_emails: true,
enable_calls: true,
billing_contact: "",
disallowed_topics: [
"docs",
"static",
"file",
"app",
"account",
"settings",
"signup",
"login",
"v1",
],
};

View file

@ -1,44 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ntfy web</title>
<head>
<meta charset="UTF-8" />
<title>ntfy web</title>
<!-- Mobile view -->
<meta name="viewport" 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 view -->
<meta
name="viewport"
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 -->
<meta name="theme-color" content="#317f6f">
<meta name="msapplication-navbutton-color" content="#317f6f">
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f">
<!-- Mobile browsers, background color -->
<meta name="theme-color" content="#317f6f" />
<meta name="msapplication-navbutton-color" content="#317f6f" />
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
<!-- Favicon, see favicon.io -->
<link rel="icon" type="image/png" href="%PUBLIC_URL%/static/images/favicon.ico">
<!-- Favicon, see favicon.io -->
<link
rel="icon"
type="image/png"
href="%PUBLIC_URL%/static/images/favicon.ico"
/>
<!-- Previews in Google, Slack, WhatsApp, etc. -->
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="ntfy web" />
<meta property="og:title" content="ntfy web" />
<meta property="og:description" content="ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." />
<meta property="og:image" content="%PUBLIC_URL%/static/images/ntfy.png" />
<meta property="og:url" content="https://ntfy.sh" />
<!-- Previews in Google, Slack, WhatsApp, etc. -->
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="ntfy web" />
<meta property="og:title" content="ntfy web" />
<meta
property="og:description"
content="ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy."
/>
<meta property="og:image" content="%PUBLIC_URL%/static/images/ntfy.png" />
<meta property="og:url" content="https://ntfy.sh" />
<!-- Never index -->
<meta name="robots" content="noindex, nofollow" />
<!-- Never index -->
<meta name="robots" content="noindex, nofollow" />
<!-- Style overrides & fonts -->
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/app.css" type="text/css">
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css">
</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>
<!-- Style overrides & fonts -->
<link
rel="stylesheet"
href="%PUBLIC_URL%/static/css/app.css"
type="text/css"
/>
<link
rel="stylesheet"
href="%PUBLIC_URL%/static/css/fonts.css"
type="text/css"
/>
</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>

View file

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

View file

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

View file

@ -1,429 +1,442 @@
import {
accountBillingPortalUrl,
accountBillingSubscriptionUrl,
accountPasswordUrl,
accountPhoneUrl,
accountPhoneVerifyUrl,
accountReservationSingleUrl,
accountReservationUrl,
accountSettingsUrl,
accountSubscriptionUrl,
accountTokenUrl,
accountUrl,
maybeWithBearerAuth,
tiersUrl,
withBasicAuth,
withBearerAuth
accountBillingPortalUrl,
accountBillingSubscriptionUrl,
accountPasswordUrl,
accountPhoneUrl,
accountPhoneVerifyUrl,
accountReservationSingleUrl,
accountReservationUrl,
accountSettingsUrl,
accountSubscriptionUrl,
accountTokenUrl,
accountUrl,
maybeWithBearerAuth,
tiersUrl,
withBasicAuth,
withBearerAuth,
} from "./utils";
import session from "./Session";
import subscriptionManager from "./SubscriptionManager";
import i18n from "i18next";
import prefs from "./Prefs";
import routes from "../components/routes";
import {fetchOrThrow, UnauthorizedError} from "./errors";
import { fetchOrThrow, UnauthorizedError } from "./errors";
const delayMillis = 45000; // 45 seconds
const intervalMillis = 900000; // 15 minutes
class AccountApi {
constructor() {
this.timer = null;
this.listener = null; // Fired when account is fetched from remote
this.tiers = null; // Cached
}
constructor() {
this.timer = null;
this.listener = null; // Fired when account is fetched from remote
this.tiers = null; // Cached
}
registerListener(listener) {
this.listener = listener;
}
registerListener(listener) {
this.listener = listener;
}
resetListener() {
this.listener = null;
}
resetListener() {
this.listener = null;
}
async login(user) {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Checking auth for ${url}`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBasicAuth({}, user.username, user.password)
});
const json = await response.json(); // May throw SyntaxError
if (!json.token) {
throw new Error(`Unexpected server response: Cannot find token`);
async login(user) {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Checking auth for ${url}`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBasicAuth({}, user.username, user.password),
});
const json = await response.json(); // May throw SyntaxError
if (!json.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;
}
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);
if (account.notification.delete_after) {
await prefs.setDeleteAfter(account.notification.delete_after);
}
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;
if (account.notification.min_priority) {
await prefs.setMinPriority(account.notification.min_priority);
}
console.log(`[AccountApi] Creating user access token ${url}`);
await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify(body)
});
}
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);
}
}
}
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())
});
startWorker() {
if (this.timer !== null) {
return;
}
console.log(`[AccountApi] Starting worker`);
this.timer = setInterval(() => this.runWorker(), intervalMillis);
setTimeout(() => this.runWorker(), delayMillis);
}
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 runWorker() {
if (!session.token()) {
return;
}
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);
}
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);
}
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
export const Role = {
ADMIN: "admin",
USER: "user"
ADMIN: "admin",
USER: "user",
};
// Maps to server.visitorLimitBasis in server/visitor.go
export const LimitBasis = {
IP: "ip",
TIER: "tier"
IP: "ip",
TIER: "tier",
};
// Maps to stripe.SubscriptionStatus
export const SubscriptionStatus = {
ACTIVE: "active",
PAST_DUE: "past_due"
ACTIVE: "active",
PAST_DUE: "past_due",
};
// Maps to stripe.PriceRecurringInterval
export const SubscriptionInterval = {
MONTH: "month",
YEAR: "year"
MONTH: "month",
YEAR: "year",
};
// Maps to user.Permission in user/types.go
export const Permission = {
READ_WRITE: "read-write",
READ_ONLY: "read-only",
WRITE_ONLY: "write-only",
DENY_ALL: "deny-all"
READ_WRITE: "read-write",
READ_ONLY: "read-only",
WRITE_ONLY: "write-only",
DENY_ALL: "deny-all",
};
const accountApi = new AccountApi();

View file

@ -1,118 +1,125 @@
import {
fetchLinesIterator,
maybeWithAuth,
topicShortUrl,
topicUrl,
topicUrlAuth,
topicUrlJsonPoll,
topicUrlJsonPollWithSince
fetchLinesIterator,
maybeWithAuth,
topicShortUrl,
topicUrl,
topicUrlAuth,
topicUrlJsonPoll,
topicUrlJsonPollWithSince,
} from "./utils";
import userManager from "./UserManager";
import {fetchOrThrow} from "./errors";
import { fetchOrThrow } from "./errors";
class Api {
async poll(baseUrl, topic, since) {
const user = await userManager.get(baseUrl);
const shortUrl = topicShortUrl(baseUrl, topic);
const url = (since)
? topicUrlJsonPollWithSince(baseUrl, topic, since)
: topicUrlJsonPoll(baseUrl, topic);
const messages = [];
const headers = maybeWithAuth({}, user);
console.log(`[Api] Polling ${url}`);
for await (let line of fetchLinesIterator(url, headers)) {
const message = JSON.parse(line);
if (message.id) {
console.log(`[Api, ${shortUrl}] Received message ${line}`);
messages.push(message);
}
}
return messages;
async poll(baseUrl, topic, since) {
const user = await userManager.get(baseUrl);
const shortUrl = topicShortUrl(baseUrl, topic);
const url = since
? topicUrlJsonPollWithSince(baseUrl, topic, since)
: topicUrlJsonPoll(baseUrl, topic);
const messages = [];
const headers = maybeWithAuth({}, user);
console.log(`[Api] Polling ${url}`);
for await (let line of fetchLinesIterator(url, headers)) {
const message = JSON.parse(line);
if (message.id) {
console.log(`[Api, ${shortUrl}] Received message ${line}`);
messages.push(message);
}
}
return messages;
}
async publish(baseUrl, topic, message, options) {
const user = await userManager.get(baseUrl);
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
const headers = {};
const body = {
topic: topic,
message: message,
...options
};
await fetchOrThrow(baseUrl, {
method: 'PUT',
body: JSON.stringify(body),
headers: maybeWithAuth(headers, user)
});
}
async publish(baseUrl, topic, message, options) {
const user = await userManager.get(baseUrl);
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
const headers = {};
const body = {
topic: topic,
message: message,
...options,
};
await fetchOrThrow(baseUrl, {
method: "PUT",
body: JSON.stringify(body),
headers: maybeWithAuth(headers, user),
});
}
/**
* 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.
*
* Firefox XHR bug:
* 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
* correct headers are clearly set. It's quite the odd behavior.
*
* There is an example, and the bug report here:
* - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755
* - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345
*/
publishXHR(url, body, headers, onProgress) {
console.log(`[Api] Publishing message to ${url}`);
const xhr = new XMLHttpRequest();
const send = new Promise(function (resolve, reject) {
xhr.open("PUT", url);
if (body.type) {
xhr.overrideMimeType(body.type);
/**
* 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.
*
* Firefox XHR bug:
* 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
* correct headers are clearly set. It's quite the odd behavior.
*
* There is an example, and the bug report here:
* - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755
* - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345
*/
publishXHR(url, body, headers, onProgress) {
console.log(`[Api] Publishing message to ${url}`);
const xhr = new XMLHttpRequest();
const send = new Promise(function (resolve, reject) {
xhr.open("PUT", url);
if (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)) {
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}`;
}
} catch (e) {
// Nothing
}
xhr.abort();
reject(errorText ?? "An error occurred");
}
})
xhr.send(body);
});
send.abort = () => {
console.log(`[Api] Publish aborted by user`);
xhr.abort();
} catch (e) {
// Nothing
}
xhr.abort();
reject(errorText ?? "An error occurred");
}
return send;
}
});
xhr.send(body);
});
send.abort = () => {
console.log(`[Api] Publish aborted by user`);
xhr.abort();
};
return send;
}
async topicAuth(baseUrl, topic, user) {
const url = topicUrlAuth(baseUrl, topic);
console.log(`[Api] Checking auth for ${url}`);
const response = await fetch(url, {
headers: maybeWithAuth({}, user)
});
if (response.status >= 200 && response.status <= 299) {
return true;
} else if (response.status === 401 || response.status === 403) { // See server/server.go
return false;
}
throw new Error(`Unexpected server response ${response.status}`);
async topicAuth(baseUrl, topic, user) {
const url = topicUrlAuth(baseUrl, topic);
console.log(`[Api] Checking auth for ${url}`);
const response = await fetch(url, {
headers: maybeWithAuth({}, user),
});
if (response.status >= 200 && response.status <= 299) {
return true;
} else if (response.status === 401 || response.status === 403) {
// See server/server.go
return false;
}
throw new Error(`Unexpected server response ${response.status}`);
}
}
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];
@ -9,110 +15,142 @@ const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
* Incoming messages and state changes are forwarded via listeners.
*/
class Connection {
constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, 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;
constructor(
connectionId,
subscriptionId,
baseUrl,
topic,
user,
since,
onNotification,
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.retryCount = 0;
this.retryTimeout = 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
);
};
}
close() {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`
);
const socket = this.ws;
const retryTimeout = this.retryTimeout;
if (socket !== null) {
socket.close();
}
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;
} 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);
};
if (retryTimeout !== null) {
clearTimeout(retryTimeout);
}
this.retryTimeout = null;
this.ws = null;
}
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) {
clearTimeout(retryTimeout);
}
this.retryTimeout = null;
this.ws = null;
wsUrl() {
const params = [];
if (this.since) {
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("&")}`;
}
wsUrl() {
const params = [];
if (this.since) {
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));
authParam() {
if (this.user.password) {
return encodeBase64Url(basicAuth(this.user.username, this.user.password));
}
return encodeBase64Url(bearerAuth(this.user.token));
}
}
export class ConnectionState {
static Connected = "connected";
static Connecting = "connecting";
static Connected = "connected";
static Connecting = "connecting";
}
export default Connection;

View file

@ -1,5 +1,5 @@
import Connection from "./Connection";
import {hashCode} from "./utils";
import { hashCode } from "./utils";
/**
* 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.
*/
class ConnectionManager {
constructor() {
this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
this.stateListener = null; // Fired when connection state changes
this.messageListener = null; // Fired when new notifications arrive
constructor() {
this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
this.stateListener = null; // Fired when connection state changes
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) {
this.stateListener = listener;
// 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
);
}
}
}
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));
// 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);
}
}
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) => {
return (user)
? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`)
: hashCode(`${subscription.id}`);
}
return user
? hashCode(
`${subscription.id}|${user.username}|${user.password ?? ""}|${
user.token ?? ""
}`
)
: hashCode(`${subscription.id}`);
};
const connectionManager = new 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 subscriptionManager from "./SubscriptionManager";
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.
*/
class Notifier {
async notify(subscriptionId, notification, onClickFallback) {
if (!this.supported()) {
return;
}
const subscription = await subscriptionManager.get(subscriptionId);
const shouldNotify = await this.shouldNotify(subscription, notification);
if (!shouldNotify) {
return;
}
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
const displayName = topicDisplayName(subscription);
const message = formatMessage(notification);
const title = formatTitleWithDefault(notification, displayName);
async notify(subscriptionId, notification, onClickFallback) {
if (!this.supported()) {
return;
}
const subscription = await subscriptionManager.get(subscriptionId);
const shouldNotify = await this.shouldNotify(subscription, notification);
if (!shouldNotify) {
return;
}
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
const displayName = topicDisplayName(subscription);
const message = formatMessage(notification);
const title = formatTitleWithDefault(notification, displayName);
// Show notification
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
const n = new Notification(title, {
body: message,
icon: logo
});
if (notification.click) {
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);
}
}
// Show notification
console.log(
`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`
);
const n = new Notification(title, {
body: message,
icon: logo,
});
if (notification.click) {
n.onclick = (e) => openUrl(notification.click);
} else {
n.onclick = () => onClickFallback(subscription);
}
granted() {
return this.supported() && Notification.permission === 'granted';
// 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);
}
}
}
maybeRequestPermission(cb) {
if (!this.supported()) {
cb(false);
return;
}
if (!this.granted()) {
Notification.requestPermission().then((permission) => {
const granted = permission === 'granted';
cb(granted);
});
}
}
granted() {
return this.supported() && Notification.permission === "granted";
}
async shouldNotify(subscription, notification) {
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;
maybeRequestPermission(cb) {
if (!this.supported()) {
cb(false);
return;
}
if (!this.granted()) {
Notification.requestPermission().then((permission) => {
const granted = permission === "granted";
cb(granted);
});
}
}
supported() {
return this.browserSupported() && this.contextSupported();
async shouldNotify(subscription, notification) {
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() {
return 'Notification' in window;
}
supported() {
return this.browserSupported() && this.contextSupported();
}
/**
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
*/
contextSupported() {
return location.protocol === 'https:'
|| location.hostname.match('^127.')
|| location.hostname === 'localhost';
}
browserSupported() {
return "Notification" in window;
}
/**
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
*/
contextSupported() {
return (
location.protocol === "https:" ||
location.hostname.match("^127.") ||
location.hostname === "localhost"
);
}
}
const notifier = new Notifier();

View file

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

View file

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

View file

@ -5,33 +5,36 @@ const delayMillis = 25000; // 25 seconds
const intervalMillis = 1800000; // 30 minutes
class Pruner {
constructor() {
this.timer = null;
}
constructor() {
this.timer = null;
}
startWorker() {
if (this.timer !== null) {
return;
}
console.log(`[Pruner] Starting worker`);
this.timer = setInterval(() => this.prune(), intervalMillis);
setTimeout(() => this.prune(), delayMillis);
startWorker() {
if (this.timer !== null) {
return;
}
console.log(`[Pruner] Starting worker`);
this.timer = setInterval(() => this.prune(), intervalMillis);
setTimeout(() => this.prune(), delayMillis);
}
async prune() {
const deleteAfterSeconds = await prefs.deleteAfter();
const pruneThresholdTimestamp = Math.round(Date.now()/1000) - deleteAfterSeconds;
if (deleteAfterSeconds === 0) {
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);
}
async prune() {
const deleteAfterSeconds = await prefs.deleteAfter();
const pruneThresholdTimestamp =
Math.round(Date.now() / 1000) - deleteAfterSeconds;
if (deleteAfterSeconds === 0) {
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);
}
}
}
const pruner = new Pruner();

View file

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

View file

@ -1,192 +1,193 @@
import db from "./db";
import {topicUrl} from "./utils";
import { topicUrl } from "./utils";
class SubscriptionManager {
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
async all() {
const subscriptions = await db.subscriptions.toArray();
await Promise.all(subscriptions.map(async s => {
s.new = await db.notifications
.where({ subscriptionId: s.id, new: 1 })
.count();
}));
return subscriptions;
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
async all() {
const subscriptions = await db.subscriptions.toArray();
await Promise.all(
subscriptions.map(async (s) => {
s.new = await db.notifications
.where({ subscriptionId: s.id, new: 1 })
.count();
})
);
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) {
return await db.subscriptions.get(subscriptionId)
// 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);
}
}
}
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 updateState(subscriptionId, state) {
db.subscriptions.update(subscriptionId, { state: state });
}
async remove(subscriptionId) {
await db.subscriptions.delete(subscriptionId);
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
}
async first() {
return db.subscriptions.toCollection().first(); // May be undefined
}
async getNotifications(subscriptionId) {
// This is quite awkward, but it is the recommended approach as per the Dexie docs.
// 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;
}
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);
}
// 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);
}
}
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;
}
async updateState(subscriptionId, state) {
db.subscriptions.update(subscriptionId, { state: state });
/** Adds/replaces notifications, will not throw if they exist */
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;
}
async remove(subscriptionId) {
await db.subscriptions.delete(subscriptionId);
await db.notifications
.where({subscriptionId: subscriptionId})
.delete();
try {
await db.notifications.put({ ...notification });
} catch (e) {
console.error(`[SubscriptionManager] Error updating notification`, e);
}
return true;
}
async first() {
return db.subscriptions.toCollection().first(); // May be undefined
}
async deleteNotification(notificationId) {
await db.notifications.delete(notificationId);
}
async getNotifications(subscriptionId) {
// This is quite awkward, but it is the recommended approach as per the Dexie docs.
// 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
async deleteNotifications(subscriptionId) {
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
}
return db.notifications
.orderBy("time") // Sort by time first
.filter(n => n.subscriptionId === subscriptionId)
.reverse()
.toArray();
}
async markNotificationRead(notificationId) {
await db.notifications.where({ id: notificationId }).modify({ new: 0 });
}
async getAllNotifications() {
return db.notifications
.orderBy("time") // Efficient, see docs
.reverse()
.toArray();
}
async markNotificationsRead(subscriptionId) {
await db.notifications
.where({ subscriptionId: subscriptionId, new: 1 })
.modify({ new: 0 });
}
/** 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 {
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;
}
async setMutedUntil(subscriptionId, mutedUntil) {
await db.subscriptions.update(subscriptionId, {
mutedUntil: mutedUntil,
});
}
/** Adds/replaces notifications, will not throw if they exist */
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 setDisplayName(subscriptionId, displayName) {
await db.subscriptions.update(subscriptionId, {
displayName: displayName,
});
}
async updateNotification(notification) {
const exists = await db.notifications.get(notification.id);
if (!exists) {
return false;
}
try {
await db.notifications.put({ ...notification });
} catch (e) {
console.error(`[SubscriptionManager] Error updating notification`, e);
}
return true;
}
async setReservation(subscriptionId, reservation) {
await db.subscriptions.update(subscriptionId, {
reservation: reservation,
});
}
async deleteNotification(notificationId) {
await db.notifications.delete(notificationId);
}
async update(subscriptionId, params) {
await db.subscriptions.update(subscriptionId, params);
}
async deleteNotifications(subscriptionId) {
await db.notifications
.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();
}
async pruneNotifications(thresholdTimestamp) {
await db.notifications.where("time").below(thresholdTimestamp).delete();
}
}
const subscriptionManager = new SubscriptionManager();

View file

@ -2,45 +2,45 @@ import db from "./db";
import session from "./Session";
class UserManager {
async all() {
const users = await db.users.toArray();
if (session.exists()) {
users.unshift(this.localUser());
}
return users;
async all() {
const users = await db.users.toArray();
if (session.exists()) {
users.unshift(this.localUser());
}
return users;
}
async get(baseUrl) {
if (session.exists() && baseUrl === config.base_url) {
return this.localUser();
}
return db.users.get(baseUrl);
async get(baseUrl) {
if (session.exists() && baseUrl === config.base_url) {
return this.localUser();
}
return db.users.get(baseUrl);
}
async save(user) {
if (session.exists() && user.baseUrl === config.base_url) {
return;
}
await db.users.put(user);
async save(user) {
if (session.exists() && user.baseUrl === config.base_url) {
return;
}
await db.users.put(user);
}
async delete(baseUrl) {
if (session.exists() && baseUrl === config.base_url) {
return;
}
await db.users.delete(baseUrl);
async delete(baseUrl) {
if (session.exists() && baseUrl === config.base_url) {
return;
}
await db.users.delete(baseUrl);
}
localUser() {
if (!session.exists()) {
return null;
}
return {
baseUrl: config.base_url,
username: session.username(),
token: session.token() // Not "password"!
};
localUser() {
if (!session.exists()) {
return null;
}
return {
baseUrl: config.base_url,
username: session.username(),
token: session.token(), // Not "password"!
};
}
}
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,
// so the frontend (hey, that's us!) can use the current location.
if (!config.base_url || config.base_url === "") {
config.base_url = window.location.origin;
config.base_url = window.location.origin;
}
export default config;

View file

@ -1,4 +1,4 @@
import Dexie from 'dexie';
import Dexie from "dexie";
import session from "./Session";
// Uses Dexie.js
@ -8,14 +8,14 @@ import session from "./Session";
// - As per docs, we only declare the indexable columns, not all columns
// 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);
db.version(1).stores({
subscriptions: '&id,baseUrl',
notifications: '&id,subscriptionId,time,new,[subscriptionId+new]', // compound key for query performance
users: '&baseUrl,username',
prefs: '&key'
subscriptions: "&id,baseUrl",
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
users: "&baseUrl,username",
prefs: "&key",
});
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
export const fetchOrThrow = async (url, options) => {
const response = await fetch(url, options);
if (response.status !== 200) {
await throwAppError(response);
}
return response; // Promise!
const response = await fetch(url, options);
if (response.status !== 200) {
await throwAppError(response);
}
return response; // Promise!
};
export const throwAppError = async (response) => {
if (response.status === 401 || response.status === 403) {
console.log(`[Error] HTTP ${response.status}`, response);
throw new UnauthorizedError();
if (response.status === 401 || response.status === 403) {
console.log(`[Error] HTTP ${response.status}`, response);
throw new UnauthorizedError();
}
const error = await maybeToJson(response);
if (error?.code) {
console.log(
`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${
error.error || ""
}`,
response
);
if (error.code === UserExistsError.CODE) {
throw new UserExistsError();
} else if (error.code === TopicReservedError.CODE) {
throw new TopicReservedError();
} else if (error.code === AccountCreateLimitReachedError.CODE) {
throw new AccountCreateLimitReachedError();
} else if (error.code === IncorrectPasswordError.CODE) {
throw new IncorrectPasswordError();
} else if (error?.error) {
throw new Error(`Error ${error.code}: ${error.error}`);
}
const error = await maybeToJson(response);
if (error?.code) {
console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response);
if (error.code === UserExistsError.CODE) {
throw new UserExistsError();
} else if (error.code === TopicReservedError.CODE) {
throw new TopicReservedError();
} else if (error.code === AccountCreateLimitReachedError.CODE) {
throw new AccountCreateLimitReachedError();
} else if (error.code === IncorrectPasswordError.CODE) {
throw new IncorrectPasswordError();
} else if (error?.error) {
throw new Error(`Error ${error.code}: ${error.error}`);
}
}
console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response);
throw new Error(`Unexpected response ${response.status}`);
}
console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response);
throw new Error(`Unexpected response ${response.status}`);
};
const maybeToJson = async (response) => {
try {
return await response.json();
} catch (e) {
return null;
}
}
try {
return await response.json();
} catch (e) {
return null;
}
};
export class UnauthorizedError extends Error {
constructor() { super("Unauthorized"); }
constructor() {
super("Unauthorized");
}
}
export class UserExistsError extends Error {
static CODE = 40901; // errHTTPConflictUserExists
constructor() { super("Username already exists"); }
static CODE = 40901; // errHTTPConflictUserExists
constructor() {
super("Username already exists");
}
}
export class TopicReservedError extends Error {
static CODE = 40902; // errHTTPConflictTopicReserved
constructor() { super("Topic already reserved"); }
static CODE = 40902; // errHTTPConflictTopicReserved
constructor() {
super("Topic already reserved");
}
}
export class AccountCreateLimitReachedError extends Error {
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
constructor() { super("Account creation limit reached"); }
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
constructor() {
super("Account creation limit reached");
}
}
export class IncorrectPasswordError extends Error {
static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation
constructor() { super("Password incorrect"); }
static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation
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 juntos from "../sounds/juntos.mp3";
import pristine from "../sounds/pristine.mp3";
@ -7,300 +7,316 @@ import dadum from "../sounds/dadum.mp3";
import pop from "../sounds/pop.mp3";
import popSwoosh from "../sounds/pop-swoosh.mp3";
import config from "./config";
import {Base64} from 'js-base64';
import { Base64 } from "js-base64";
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("http://", "ws://");
export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
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 topicUrlJson = (baseUrl, topic) =>
`${topicUrl(baseUrl, topic)}/json`;
export const topicUrlJsonPoll = (baseUrl, topic) =>
`${topicUrlJson(baseUrl, topic)}?poll=1`;
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 accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`;
export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reservation`;
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 accountSubscriptionUrl = (baseUrl) =>
`${baseUrl}/v1/account/subscription`;
export const accountReservationUrl = (baseUrl) =>
`${baseUrl}/v1/account/reservation`;
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 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 shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
export const expandSecureUrl = (url) => `https://${url}`;
export const validUrl = (url) => {
return url.match(/^https?:\/\/.+/);
}
return url.match(/^https?:\/\/.+/);
};
export const validTopic = (topic) => {
if (disallowedTopic(topic)) {
return false;
}
return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
}
if (disallowedTopic(topic)) {
return false;
}
return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
};
export const disallowedTopic = (topic) => {
return config.disallowed_topics.includes(topic);
}
return config.disallowed_topics.includes(topic);
};
export const topicDisplayName = (subscription) => {
if (subscription.displayName) {
return subscription.displayName;
} else if (subscription.baseUrl === config.base_url) {
return subscription.topic;
}
return topicShortUrl(subscription.baseUrl, subscription.topic);
if (subscription.displayName) {
return subscription.displayName;
} else if (subscription.baseUrl === config.base_url) {
return subscription.topic;
}
return topicShortUrl(subscription.baseUrl, subscription.topic);
};
// Format emojis (see emoji.js)
const emojis = {};
rawEmojis.forEach(emoji => {
emoji.aliases.forEach(alias => {
emojis[alias] = emoji.emoji;
});
rawEmojis.forEach((emoji) => {
emoji.aliases.forEach((alias) => {
emojis[alias] = emoji.emoji;
});
});
const toEmojis = (tags) => {
if (!tags) return [];
else return tags.filter(tag => tag in emojis).map(tag => emojis[tag]);
}
if (!tags) return [];
else return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]);
};
export const formatTitleWithDefault = (m, fallback) => {
if (m.title) {
return formatTitle(m);
}
return fallback;
if (m.title) {
return formatTitle(m);
}
return fallback;
};
export const formatTitle = (m) => {
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.title}`;
} else {
return m.title;
}
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.title}`;
} else {
return m.title;
}
};
export const formatMessage = (m) => {
if (m.title) {
return m.message;
if (m.title) {
return m.message;
} else {
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.message}`;
} else {
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.message}`;
} else {
return m.message;
}
return m.message;
}
}
};
export const unmatchedTags = (tags) => {
if (!tags) return [];
else return tags.filter(tag => !(tag in emojis));
}
if (!tags) return [];
else return tags.filter((tag) => !(tag in emojis));
};
export const maybeWithAuth = (headers, user) => {
if (user && user.password) {
return withBasicAuth(headers, user.username, user.password);
} else if (user && user.token) {
return withBearerAuth(headers, user.token);
}
return headers;
}
if (user && user.password) {
return withBasicAuth(headers, user.username, user.password);
} else if (user && user.token) {
return withBearerAuth(headers, user.token);
}
return headers;
};
export const maybeWithBearerAuth = (headers, token) => {
if (token) {
return withBearerAuth(headers, token);
}
return headers;
}
if (token) {
return withBearerAuth(headers, token);
}
return headers;
};
export const withBasicAuth = (headers, username, password) => {
headers['Authorization'] = basicAuth(username, password);
return headers;
}
headers["Authorization"] = basicAuth(username, password);
return headers;
};
export const basicAuth = (username, password) => {
return `Basic ${encodeBase64(`${username}:${password}`)}`;
}
return `Basic ${encodeBase64(`${username}:${password}`)}`;
};
export const withBearerAuth = (headers, token) => {
headers['Authorization'] = bearerAuth(token);
return headers;
}
headers["Authorization"] = bearerAuth(token);
return headers;
};
export const bearerAuth = (token) => {
return `Bearer ${token}`;
}
return `Bearer ${token}`;
};
export const encodeBase64 = (s) => {
return Base64.encode(s);
}
return Base64.encode(s);
};
export const encodeBase64Url = (s) => {
return Base64.encodeURI(s);
}
return Base64.encodeURI(s);
};
export const maybeAppendActionErrors = (message, notification) => {
const actionErrors = (notification.actions ?? [])
.map(action => action.error)
.filter(action => !!action)
.join("\n")
if (actionErrors.length === 0) {
return message;
} else {
return `${message}\n\n${actionErrors}`;
}
}
const actionErrors = (notification.actions ?? [])
.map((action) => action.error)
.filter((action) => !!action)
.join("\n");
if (actionErrors.length === 0) {
return message;
} else {
return `${message}\n\n${actionErrors}`;
}
};
export const shuffle = (arr) => {
let j, x;
for (let index = arr.length - 1; index > 0; index--) {
j = Math.floor(Math.random() * (index + 1));
x = arr[index];
arr[index] = arr[j];
arr[j] = x;
}
return arr;
}
let j, x;
for (let index = arr.length - 1; index > 0; index--) {
j = Math.floor(Math.random() * (index + 1));
x = arr[index];
arr[index] = arr[j];
arr[j] = x;
}
return arr;
};
export const splitNoEmpty = (s, delimiter) => {
return s
.split(delimiter)
.map(x => x.trim())
.filter(x => x !== "");
}
return s
.split(delimiter)
.map((x) => x.trim())
.filter((x) => x !== "");
};
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
export const hashCode = async (s) => {
let hash = 0;
for (let i = 0; i < s.length; i++) {
const char = s.charCodeAt(i);
hash = ((hash<<5)-hash)+char;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}
let hash = 0;
for (let i = 0; i < s.length; i++) {
const char = s.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
};
export const formatShortDateTime = (timestamp) => {
return new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'})
.format(new Date(timestamp * 1000));
}
return new Intl.DateTimeFormat("default", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(timestamp * 1000));
};
export const formatShortDate = (timestamp) => {
return new Intl.DateTimeFormat('default', {dateStyle: 'short'})
.format(new Date(timestamp * 1000));
}
return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(
new Date(timestamp * 1000)
);
};
export const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return '0 bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
if (bytes === 0) return "0 bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
};
export const formatNumber = (n) => {
if (n === 0) {
return n;
} else if (n % 1000 === 0) {
return `${n/1000}k`;
}
return n.toLocaleString();
}
if (n === 0) {
return n;
} else if (n % 1000 === 0) {
return `${n / 1000}k`;
}
return n.toLocaleString();
};
export const formatPrice = (n) => {
if (n % 100 === 0) {
return `$${n/100}`;
}
return `$${(n/100).toPrecision(2)}`;
}
if (n % 100 === 0) {
return `$${n / 100}`;
}
return `$${(n / 100).toPrecision(2)}`;
};
export const openUrl = (url) => {
window.open(url, "_blank", "noopener,noreferrer");
window.open(url, "_blank", "noopener,noreferrer");
};
export const sounds = {
"ding": {
file: ding,
label: "Ding"
},
"juntos": {
file: juntos,
label: "Juntos"
},
"pristine": {
file: pristine,
label: "Pristine"
},
"dadum": {
file: dadum,
label: "Dadum"
},
"pop": {
file: pop,
label: "Pop"
},
"pop-swoosh": {
file: popSwoosh,
label: "Pop swoosh"
},
"beep": {
file: beep,
label: "Beep"
}
ding: {
file: ding,
label: "Ding",
},
juntos: {
file: juntos,
label: "Juntos",
},
pristine: {
file: pristine,
label: "Pristine",
},
dadum: {
file: dadum,
label: "Dadum",
},
pop: {
file: pop,
label: "Pop",
},
"pop-swoosh": {
file: popSwoosh,
label: "Pop swoosh",
},
beep: {
file: beep,
label: "Beep",
},
};
export const playSound = async (id) => {
const audio = new Audio(sounds[id].file);
return audio.play();
const audio = new Audio(sounds[id].file);
return audio.play();
};
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
export async function* fetchLinesIterator(fileURL, headers) {
const utf8Decoder = new TextDecoder('utf-8');
const response = await fetch(fileURL, {
headers: headers
});
const reader = response.body.getReader();
let { value: chunk, done: readerDone } = await reader.read();
chunk = chunk ? utf8Decoder.decode(chunk) : '';
const utf8Decoder = new TextDecoder("utf-8");
const response = await fetch(fileURL, {
headers: headers,
});
const reader = response.body.getReader();
let { value: chunk, done: readerDone } = await reader.read();
chunk = chunk ? utf8Decoder.decode(chunk) : "";
const re = /\n|\r|\r\n/gm;
let startIndex = 0;
const re = /\n|\r|\r\n/gm;
let startIndex = 0;
for (;;) {
let result = re.exec(chunk);
if (!result) {
if (readerDone) {
break;
}
let remainder = chunk.substr(startIndex);
({ value: chunk, done: readerDone } = await reader.read());
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : '');
startIndex = re.lastIndex = 0;
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
for (;;) {
let result = re.exec(chunk);
if (!result) {
if (readerDone) {
break;
}
let remainder = chunk.substr(startIndex);
({ value: chunk, done: readerDone } = await reader.read());
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : "");
startIndex = re.lastIndex = 0;
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
}
}
export const randomAlphanumericString = (len) => {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let id = "";
for (let i = 0; i < len; i++) {
id += alphabet[(Math.random() * alphabet.length) | 0];
}
return id;
}
const alphabet =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let id = "";
for (let i = 0; i < len; i++) {
id += alphabet[(Math.random() * alphabet.length) | 0];
}
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 Typography from "@mui/material/Typography";
import * as React from "react";
import {useState} from "react";
import { useState } from "react";
import Box from "@mui/material/Box";
import {topicDisplayName} from "../app/utils";
import { topicDisplayName } from "../app/utils";
import db from "../app/db";
import {useLocation, useNavigate} from "react-router-dom";
import MenuItem from '@mui/material/MenuItem';
import { useLocation, useNavigate } from "react-router-dom";
import MenuItem from "@mui/material/MenuItem";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsIcon from '@mui/icons-material/Notifications';
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
import NotificationsIcon from "@mui/icons-material/Notifications";
import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
import routes from "./routes";
import subscriptionManager from "../app/SubscriptionManager";
import logo from "../img/ntfy.svg";
import {useTranslation} from "react-i18next";
import { useTranslation } from "react-i18next";
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 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 accountApi from "../app/AccountApi";
import PopupMenu from "./PopupMenu";
import { SubscriptionPopup } from "./SubscriptionPopup";
const ActionBar = (props) => {
const { t } = useTranslation();
const location = useLocation();
let title = "ntfy";
if (props.selected) {
title = topicDisplayName(props.selected);
} else if (location.pathname === routes.settings) {
title = t("action_bar_settings");
} else if (location.pathname === routes.account) {
title = t("action_bar_account");
}
return (
<AppBar position="fixed" sx={{
width: '100%',
zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
ml: { sm: `${Navigation.width}px` }
}}>
<Toolbar sx={{
pr: '24px',
background: "linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%)"
}}>
<IconButton
color="inherit"
edge="start"
aria-label={t("action_bar_show_menu")}
onClick={props.onMobileDrawerToggle}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Box
component="img"
src={logo}
alt={t("action_bar_logo_alt")}
sx={{
display: { xs: 'none', sm: 'block' },
marginRight: '10px',
height: '28px'
}}
/>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{title}
</Typography>
{props.selected &&
<SettingsIcons
subscription={props.selected}
onUnsubscribe={props.onUnsubscribe}
/>}
<ProfileIcon/>
</Toolbar>
</AppBar>
);
const { t } = useTranslation();
const location = useLocation();
let title = "ntfy";
if (props.selected) {
title = topicDisplayName(props.selected);
} else if (location.pathname === routes.settings) {
title = t("action_bar_settings");
} else if (location.pathname === routes.account) {
title = t("action_bar_account");
}
return (
<AppBar
position="fixed"
sx={{
width: "100%",
zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
ml: { sm: `${Navigation.width}px` },
}}
>
<Toolbar
sx={{
pr: "24px",
background:
"linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%)",
}}
>
<IconButton
color="inherit"
edge="start"
aria-label={t("action_bar_show_menu")}
onClick={props.onMobileDrawerToggle}
sx={{ mr: 2, display: { sm: "none" } }}
>
<MenuIcon />
</IconButton>
<Box
component="img"
src={logo}
alt={t("action_bar_logo_alt")}
sx={{
display: { xs: "none", sm: "block" },
marginRight: "10px",
height: "28px",
}}
/>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{title}
</Typography>
{props.selected && (
<SettingsIcons
subscription={props.selected}
onUnsubscribe={props.onUnsubscribe}
/>
)}
<ProfileIcon />
</Toolbar>
</AppBar>
);
};
const SettingsIcons = (props) => {
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null);
const subscription = props.subscription;
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null);
const subscription = props.subscription;
const handleToggleMute = async () => {
const mutedUntil = (subscription.mutedUntil) ? 0 : 1; // Make this a timestamp in the future
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
}
const handleToggleMute = async () => {
const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
};
return (
<>
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} aria-label={t("action_bar_toggle_mute")}>
{subscription.mutedUntil ? <NotificationsOffIcon/> : <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)}
/>
</>
);
return (
<>
<IconButton
color="inherit"
size="large"
edge="end"
onClick={handleToggleMute}
aria-label={t("action_bar_toggle_mute")}
>
{subscription.mutedUntil ? (
<NotificationsOffIcon />
) : (
<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 { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const navigate = useNavigate();
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const navigate = useNavigate();
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleLogout = async () => {
try {
await accountApi.logout();
await db.delete();
} finally {
session.resetAndRedirect(routes.app);
}
};
const handleLogout = async () => {
try {
await accountApi.logout();
await db.delete();
} finally {
session.resetAndRedirect(routes.app);
}
};
return (
<>
{session.exists() &&
<IconButton color="inherit" size="large" edge="end" onClick={handleClick} aria-label={t("action_bar_profile_title")}>
<AccountCircleIcon/>
</IconButton>
}
{!session.exists() && config.enable_login &&
<Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{m: 1}} aria-label={t("action_bar_sign_in")}>
{t("action_bar_sign_in")}
</Button>
}
{!session.exists() && config.enable_signup &&
<Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}>
{t("action_bar_sign_up")}
</Button>
}
<PopupMenu
horizontal="right"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
>
<MenuItem onClick={() => navigate(routes.account)}>
<ListItemIcon>
<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>
</>
);
return (
<>
{session.exists() && (
<IconButton
color="inherit"
size="large"
edge="end"
onClick={handleClick}
aria-label={t("action_bar_profile_title")}
>
<AccountCircleIcon />
</IconButton>
)}
{!session.exists() && config.enable_login && (
<Button
color="inherit"
variant="text"
onClick={() => navigate(routes.login)}
sx={{ m: 1 }}
aria-label={t("action_bar_sign_in")}
>
{t("action_bar_sign_in")}
</Button>
)}
{!session.exists() && config.enable_signup && (
<Button
color="inherit"
variant="outlined"
onClick={() => navigate(routes.signup)}
aria-label={t("action_bar_sign_up")}
>
{t("action_bar_sign_up")}
</Button>
)}
<PopupMenu
horizontal="right"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
>
<MenuItem onClick={() => navigate(routes.account)}>
<ListItemIcon>
<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;

View file

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

View file

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

View file

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

View file

@ -4,30 +4,30 @@ import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions";
const DialogFooter = (props) => {
return (
<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'
}}
>
{props.status}
</DialogContentText>
<DialogActions sx={{paddingRight: 2}}>
{props.children}
</DialogActions>
</Box>
);
return (
<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",
}}
>
{props.status}
</DialogContentText>
<DialogActions sx={{ paddingRight: 2 }}>{props.children}</DialogActions>
</Box>
);
};
export default DialogFooter;

View file

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

View file

@ -1,128 +1,151 @@
import * as React from "react";
import StackTrace from "stacktrace-js";
import {CircularProgress, Link} from "@mui/material";
import { CircularProgress, Link } from "@mui/material";
import Button from "@mui/material/Button";
import {Trans, withTranslation} from "react-i18next";
import { Trans, withTranslation } from "react-i18next";
class ErrorBoundaryImpl extends React.Component {
constructor(props) {
super(props);
this.state = {
error: false,
originalStack: null,
niceStack: null,
unsupportedIndexedDB: false
};
constructor(props) {
super(props);
this.state = {
error: false,
originalStack: null,
niceStack: null,
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) {
console.error("[ErrorBoundary] Error caught", error, info);
handleError(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
// - 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);
// 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 });
});
}
if (isUnsupportedIndexedDB) {
this.handleUnsupportedIndexedDB();
} else {
this.handleError(error, info);
}
handleUnsupportedIndexedDB() {
this.setState({
error: true,
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) {
// 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}`
});
// 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 });
});
render() {
if (this.state.error) {
if (this.state.unsupportedIndexedDB) {
return this.renderUnsupportedIndexedDB();
} else {
return this.renderError();
}
}
return this.props.children;
}
handleUnsupportedIndexedDB() {
this.setState({
error: true,
unsupportedIndexedDB: true
});
}
renderUnsupportedIndexedDB() {
const { t } = this.props;
return (
<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() {
let stack = "";
if (this.state.niceStack) {
stack += `${this.state.niceStack}\n\n`;
}
stack += `${this.state.originalStack}\n`;
navigator.clipboard.writeText(stack);
}
render() {
if (this.state.error) {
if (this.state.unsupportedIndexedDB) {
return this.renderUnsupportedIndexedDB();
} else {
return this.renderError();
}
}
return this.props.children;
}
renderUnsupportedIndexedDB() {
const { t } = this.props;
return (
<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>
);
}
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>
);
}
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

View file

@ -1,122 +1,135 @@
import * as React from 'react';
import {useState} from 'react';
import * as React from "react";
import { useState } from "react";
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 Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import routes from "./routes";
import session from "../app/Session";
import {NavLink} from "react-router-dom";
import { NavLink } from "react-router-dom";
import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next";
import { useTranslation } from "react-i18next";
import accountApi from "../app/AccountApi";
import IconButton from "@mui/material/IconButton";
import {InputAdornment} from "@mui/material";
import {Visibility, VisibilityOff} from "@mui/icons-material";
import {UnauthorizedError} from "../app/errors";
import { InputAdornment } from "@mui/material";
import { Visibility, VisibilityOff } from "@mui/icons-material";
import { UnauthorizedError } from "../app/errors";
const Login = () => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const { t } = useTranslation();
const [error, setError] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault();
const user = { username, password };
try {
const token = await accountApi.login(user);
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
session.store(user.username, token);
window.location.href = routes.app;
} catch (e) {
console.log(`[Login] User auth for user ${user.username} failed`, e);
if (e instanceof UnauthorizedError) {
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>
);
const handleSubmit = async (event) => {
event.preventDefault();
const user = { username, password };
try {
const token = await accountApi.login(user);
console.log(
`[Login] User auth for user ${user.username} successful, token is ${token}`
);
session.store(user.username, token);
window.location.href = routes.app;
} catch (e) {
console.log(`[Login] User auth for user ${user.username} failed`, e);
if (e instanceof UnauthorizedError) {
setError(t("Login failed: Invalid username or password"));
} else {
setError(e.message);
}
}
};
if (!config.enable_login) {
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>
<AvatarBox>
<Typography sx={{ typography: "h6" }}>{t("login_disabled")}</Typography>
</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;

View file

@ -1,5 +1,5 @@
import * as React from 'react';
import {useState} from 'react';
import * as React from "react";
import { useState } from "react";
import Navigation from "./Navigation";
import Paper from "@mui/material/Paper";
import IconButton from "@mui/material/IconButton";
@ -7,108 +7,135 @@ import TextField from "@mui/material/TextField";
import SendIcon from "@mui/icons-material/Send";
import api from "../app/Api";
import PublishDialog from "./PublishDialog";
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import {Portal, Snackbar} from "@mui/material";
import {useTranslation} from "react-i18next";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { Portal, Snackbar } from "@mui/material";
import { useTranslation } from "react-i18next";
const Messaging = (props) => {
const [message, setMessage] = useState("");
const [dialogKey, setDialogKey] = useState(0);
const [message, setMessage] = useState("");
const [dialogKey, setDialogKey] = useState(0);
const dialogOpenMode = props.dialogOpenMode;
const subscription = props.selected;
const dialogOpenMode = props.dialogOpenMode;
const subscription = props.selected;
const handleOpenDialogClick = () => {
props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT);
};
const handleOpenDialogClick = () => {
props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT);
};
const handleDialogClose = () => {
props.onDialogOpenModeChange("");
setDialogKey(prev => prev+1);
};
const handleDialogClose = () => {
props.onDialogOpenModeChange("");
setDialogKey((prev) => prev + 1);
};
return (
<>
{subscription && <MessageBar
subscription={subscription}
message={message}
onMessageChange={setMessage}
onOpenDialogClick={handleOpenDialogClick}
/>}
<PublishDialog
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
openMode={dialogOpenMode}
baseUrl={subscription?.baseUrl ?? config.base_url}
topic={subscription?.topic ?? ""}
message={message}
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)}
/>
</>
);
}
return (
<>
{subscription && (
<MessageBar
subscription={subscription}
message={message}
onMessageChange={setMessage}
onOpenDialogClick={handleOpenDialogClick}
/>
)}
<PublishDialog
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
openMode={dialogOpenMode}
baseUrl={subscription?.baseUrl ?? config.base_url}
topic={subscription?.topic ?? ""}
message={message}
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 { t } = useTranslation();
const subscription = props.subscription;
const [snackOpen, setSnackOpen] = useState(false);
const handleSendClick = async () => {
try {
await api.publish(subscription.baseUrl, subscription.topic, props.message);
} catch (e) {
console.log(`[MessageBar] Error publishing message`, e);
setSnackOpen(true);
}
props.onMessageChange("");
};
return (
<Paper
elevation={3}
sx={{
display: "flex",
position: 'fixed',
bottom: 0,
right: 0,
padding: 2,
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
}}
>
<IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick} aria-label={t("message_bar_show_dialog")}>
<KeyboardArrowUpIcon/>
</IconButton>
<TextField
autoFocus
margin="dense"
placeholder={t("message_bar_type_message")}
aria-label={t("message_bar_type_message")}
role="textbox"
type="text"
fullWidth
variant="standard"
value={props.message}
onChange={ev => props.onMessageChange(ev.target.value)}
onKeyPress={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleSendClick();
}
}}
/>
<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>
);
const { t } = useTranslation();
const subscription = props.subscription;
const [snackOpen, setSnackOpen] = useState(false);
const handleSendClick = async () => {
try {
await api.publish(
subscription.baseUrl,
subscription.topic,
props.message
);
} catch (e) {
console.log(`[MessageBar] Error publishing message`, e);
setSnackOpen(true);
}
props.onMessageChange("");
};
return (
<Paper
elevation={3}
sx={{
display: "flex",
position: "fixed",
bottom: 0,
right: 0,
padding: 2,
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}}
>
<IconButton
color="inherit"
size="large"
edge="start"
onClick={props.onOpenDialogClick}
aria-label={t("message_bar_show_dialog")}
>
<KeyboardArrowUpIcon />
</IconButton>
<TextField
autoFocus
margin="dense"
placeholder={t("message_bar_type_message")}
aria-label={t("message_bar_type_message")}
role="textbox"
type="text"
fullWidth
variant="standard"
value={props.message}
onChange={(ev) => props.onMessageChange(ev.target.value)}
onKeyPress={(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
handleSendClick();
}
}}
/>
<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;

View file

@ -1,6 +1,6 @@
import Drawer from "@mui/material/Drawer";
import * as React from "react";
import {useContext, useState} from "react";
import { useContext, useState } from "react";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
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 AddIcon from "@mui/icons-material/Add";
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 Typography from "@mui/material/Typography";
import {openUrl, topicDisplayName, topicUrl} from "../app/utils";
import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
import routes from "./routes";
import {ConnectionState} from "../app/Connection";
import {useLocation, useNavigate} from "react-router-dom";
import { ConnectionState } from "../app/Connection";
import { useLocation, useNavigate } from "react-router-dom";
import subscriptionManager from "../app/SubscriptionManager";
import {ChatBubble, MoreVert, NotificationsOffOutlined, Send} from "@mui/icons-material";
import {
ChatBubble,
MoreVert,
NotificationsOffOutlined,
Send,
} from "@mui/icons-material";
import Box from "@mui/material/Box";
import notifier from "../app/Notifier";
import config from "../app/config";
import ArticleIcon from '@mui/icons-material/Article';
import {Trans, useTranslation} from "react-i18next";
import ArticleIcon from "@mui/icons-material/Article";
import { Trans, useTranslation } from "react-i18next";
import session from "../app/Session";
import accountApi, {Permission, Role} from "../app/AccountApi";
import CelebrationIcon from '@mui/icons-material/Celebration';
import accountApi, { Permission, Role } from "../app/AccountApi";
import CelebrationIcon from "@mui/icons-material/Celebration";
import UpgradeDialog from "./UpgradeDialog";
import {AccountContext} from "./App";
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
import { AccountContext } from "./App";
import {
PermissionDenyAll,
PermissionRead,
PermissionReadWrite,
PermissionWrite,
} from "./ReserveIcons";
import IconButton from "@mui/material/IconButton";
import { SubscriptionPopup } from "./SubscriptionPopup";
const navWidth = 280;
const Navigation = (props) => {
const navigationList = <NavList {...props}/>;
return (
<Box
component="nav"
role="navigation"
sx={{width: {sm: Navigation.width}, flexShrink: {sm: 0}}}
>
{/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
<Drawer
variant="temporary"
role="menubar"
open={props.mobileDrawerOpen}
onClose={props.onMobileDrawerToggle}
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
}}
>
{navigationList}
</Drawer>
{/* Big screen drawer; persistent, shown if screen is big */}
<Drawer
open
variant="permanent"
role="menubar"
sx={{
display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
}}
>
{navigationList}
</Drawer>
</Box>
);
const navigationList = <NavList {...props} />;
return (
<Box
component="nav"
role="navigation"
sx={{ width: { sm: Navigation.width }, flexShrink: { sm: 0 } }}
>
{/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
<Drawer
variant="temporary"
role="menubar"
open={props.mobileDrawerOpen}
onClose={props.onMobileDrawerToggle}
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
sx={{
display: { xs: "block", sm: "none" },
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
}}
>
{navigationList}
</Drawer>
{/* Big screen drawer; persistent, shown if screen is big */}
<Drawer
open
variant="permanent"
role="menubar"
sx={{
display: { xs: "none", sm: "block" },
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
}}
>
{navigationList}
</Drawer>
</Box>
);
};
Navigation.width = navWidth;
const NavList = (props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { account } = useContext(AccountContext);
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { account } = useContext(AccountContext);
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
const handleSubscribeReset = () => {
setSubscribeDialogOpen(false);
setSubscribeDialogKey(prev => prev+1);
}
const handleSubscribeReset = () => {
setSubscribeDialogOpen(false);
setSubscribeDialogKey((prev) => prev + 1);
};
const handleSubscribeSubmit = (subscription) => {
console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
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 handleSubscribeSubmit = (subscription) => {
console.log(
`[Navigation] New subscription: ${subscription.id}`,
subscription
);
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 { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const handleClick = () => {
setDialogKey(k => k + 1);
setDialogOpen(true);
};
const handleClick = () => {
setDialogKey((k) => k + 1);
setDialogOpen(true);
};
return (
<Box sx={{
position: "fixed",
width: `${Navigation.width - 1}px`,
bottom: 0,
mt: 'auto',
background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
}}>
<Divider/>
<ListItemButton onClick={handleClick} sx={{pt: 2, pb: 2}}>
<ListItemIcon><CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large"/></ListItemIcon>
<ListItemText
sx={{ ml: 1 }}
primary={t("nav_upgrade_banner_label")}
secondary={t("nav_upgrade_banner_description")}
primaryTypographyProps={{
style: {
fontWeight: 500,
fontSize: "1.1rem",
background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent"
}
}}
secondaryTypographyProps={{
style: {
fontSize: "1rem"
}
}}
/>
</ListItemButton>
<UpgradeDialog
key={`upgradeDialog${dialogKey}`}
open={dialogOpen}
onCancel={() => setDialogOpen(false)}
/>
</Box>
);
return (
<Box
sx={{
position: "fixed",
width: `${Navigation.width - 1}px`,
bottom: 0,
mt: "auto",
background:
"linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
}}
>
<Divider />
<ListItemButton onClick={handleClick} sx={{ pt: 2, pb: 2 }}>
<ListItemIcon>
<CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large" />
</ListItemIcon>
<ListItemText
sx={{ ml: 1 }}
primary={t("nav_upgrade_banner_label")}
secondary={t("nav_upgrade_banner_description")}
primaryTypographyProps={{
style: {
fontWeight: 500,
fontSize: "1.1rem",
background:
"-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
},
}}
secondaryTypographyProps={{
style: {
fontSize: "1rem",
},
}}
/>
</ListItemButton>
<UpgradeDialog
key={`upgradeDialog${dialogKey}`}
open={dialogOpen}
onCancel={() => setDialogOpen(false)}
/>
</Box>
);
};
const SubscriptionList = (props) => {
const sortedSubscriptions = props.subscriptions
.filter(s => !s.internal)
.sort((a, b) => {
return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1;
});
return (
<>
{sortedSubscriptions.map(subscription =>
<SubscriptionItem
key={subscription.id}
subscription={subscription}
selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id}
/>)}
</>
);
}
const sortedSubscriptions = props.subscriptions
.filter((s) => !s.internal)
.sort((a, b) => {
return topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)
? -1
: 1;
});
return (
<>
{sortedSubscriptions.map((subscription) => (
<SubscriptionItem
key={subscription.id}
subscription={subscription}
selected={
props.selectedSubscription &&
props.selectedSubscription.id === subscription.id
}
/>
))}
</>
);
};
const SubscriptionItem = (props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
const { t } = useTranslation();
const navigate = useNavigate();
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
const subscription = props.subscription;
const iconBadge = (subscription.new <= 99) ? subscription.new : "99+";
const displayName = topicDisplayName(subscription);
const ariaLabel = (subscription.state === ConnectionState.Connecting)
? `${displayName} (${t("nav_button_connecting")})`
: displayName;
const icon = (subscription.state === ConnectionState.Connecting)
? <CircularProgress size="24px"/>
: <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>;
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 subscription = props.subscription;
const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
const displayName = topicDisplayName(subscription);
const ariaLabel =
subscription.state === ConnectionState.Connecting
? `${displayName} (${t("nav_button_connecting")})`
: displayName;
const icon =
subscription.state === ConnectionState.Connecting ? (
<CircularProgress size="24px" />
) : (
<Badge
badgeContent={iconBadge}
invisible={subscription.new === 0}
color="primary"
>
<ChatBubbleOutlineIcon />
</Badge>
);
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 { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{paddingTop: 2}}>
<AlertTitle>{t("alert_grant_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_grant_description")}</Typography>
<Button
sx={{float: 'right'}}
color="inherit"
size="small"
onClick={props.onRequestPermissionClick}
>
{t("alert_grant_button")}
</Button>
</Alert>
<Divider/>
</>
);
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_grant_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_grant_description")}</Typography>
<Button
sx={{ float: "right" }}
color="inherit"
size="small"
onClick={props.onRequestPermissionClick}
>
{t("alert_grant_button")}
</Button>
</Alert>
<Divider />
</>
);
};
const NotificationBrowserNotSupportedAlert = () => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{paddingTop: 2}}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_not_supported_description")}</Typography>
</Alert>
<Divider/>
</>
);
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom>
{t("alert_not_supported_description")}
</Typography>
</Alert>
<Divider />
</>
);
};
const NotificationContextNotSupportedAlert = () => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{paddingTop: 2}}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom>
<Trans
i18nKey="alert_not_supported_context_description"
components={{
mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener"/>
}}
/>
</Typography>
</Alert>
<Divider/>
</>
);
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom>
<Trans
i18nKey="alert_not_supported_context_description"
components={{
mdnLink: (
<Link
href="https://developer.mozilla.org/en-US/docs/Web/API/notification"
target="_blank"
rel="noopener"
/>
),
}}
/>
</Typography>
</Alert>
<Divider />
</>
);
};
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";
const PopupMenu = (props) => {
const horizontal = props.horizontal ?? "left";
const arrow = (horizontal === "right") ? { right: 19 } : { left: 19 };
return (
<Menu
anchorEl={props.anchorEl}
open={props.open}
onClose={props.onClose}
onClick={props.onClose}
TransitionComponent={Fade}
PaperProps={{
elevation: 0,
sx: {
overflow: 'visible',
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
mt: 1.5,
'& .MuiAvatar-root': {
width: 32,
height: 32,
ml: -0.5,
mr: 1,
},
'&:before': {
content: '""',
display: 'block',
position: 'absolute',
top: 0,
width: 10,
height: 10,
bgcolor: 'background.paper',
transform: 'translateY(-50%) rotate(45deg)',
zIndex: 0,
...arrow
},
},
}}
transformOrigin={{ horizontal: horizontal, vertical: 'top' }}
anchorOrigin={{ horizontal: horizontal, vertical: 'bottom' }}
>
{props.children}
</Menu>
);
const horizontal = props.horizontal ?? "left";
const arrow = horizontal === "right" ? { right: 19 } : { left: 19 };
return (
<Menu
anchorEl={props.anchorEl}
open={props.open}
onClose={props.onClose}
onClick={props.onClose}
TransitionComponent={Fade}
PaperProps={{
elevation: 0,
sx: {
overflow: "visible",
filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))",
mt: 1.5,
"& .MuiAvatar-root": {
width: 32,
height: 32,
ml: -0.5,
mr: 1,
},
"&:before": {
content: '""',
display: "block",
position: "absolute",
top: 0,
width: 10,
height: 10,
bgcolor: "background.paper",
transform: "translateY(-50%) rotate(45deg)",
zIndex: 0,
...arrow,
},
},
}}
transformOrigin={{ horizontal: horizontal, vertical: "top" }}
anchorOrigin={{ horizontal: horizontal, vertical: "bottom" }}
>
{props.children}
</Menu>
);
};
export default PopupMenu;

View file

@ -1,51 +1,54 @@
import * as React from "react";
export const PrefGroup = (props) => {
return (
<div role="table">
{props.children}
</div>
)
return <div role="table">{props.children}</div>;
};
export const Pref = (props) => {
const justifyContent = (props.alignTop) ? "normal" : "center";
return (
<div
role="row"
style={{
display: "flex",
flexDirection: "row",
marginTop: "10px",
marginBottom: "20px",
}}
>
<div
role="cell"
id={props.labelId ?? ""}
aria-label={props.title}
style={{
flex: '1 0 40%',
display: 'flex',
flexDirection: 'column',
justifyContent: justifyContent,
paddingRight: '30px'
}}
>
<div><b>{props.title}</b>{props.subtitle && <em> ({props.subtitle})</em>}</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>
const justifyContent = props.alignTop ? "normal" : "center";
return (
<div
role="row"
style={{
display: "flex",
flexDirection: "row",
marginTop: "10px",
marginBottom: "20px",
}}
>
<div
role="cell"
id={props.labelId ?? ""}
aria-label={props.title}
style={{
flex: "1 0 40%",
display: "flex",
flexDirection: "column",
justifyContent: justifyContent,
paddingRight: "30px",
}}
>
<div>
<b>{props.title}</b>
{props.subtitle && <em> ({props.subtitle})</em>}
</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 {useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import {Alert, FormControl, Select, useMediaQuery} from "@mui/material";
import * as React from "react";
import { useState } from "react";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import { Alert, FormControl, Select, useMediaQuery } from "@mui/material";
import theme from "./theme";
import {validTopic} from "../app/utils";
import { validTopic } from "../app/utils";
import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
import { useTranslation } from "react-i18next";
import session from "../app/Session";
import routes from "./routes";
import accountApi, {Permission} from "../app/AccountApi";
import accountApi, { Permission } from "../app/AccountApi";
import ReserveTopicSelect from "./ReserveTopicSelect";
import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import {Check, DeleteForever} from "@mui/icons-material";
import {TopicReservedError, UnauthorizedError} from "../app/errors";
import { Check, DeleteForever } from "@mui/icons-material";
import { TopicReservedError, UnauthorizedError } from "../app/errors";
export const ReserveAddDialog = (props) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [topic, setTopic] = useState(props.topic || "");
const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const allowTopicEdit = !props.topic;
const alreadyReserved = props.reservations.filter(r => r.topic === topic).length > 0;
const submitButtonEnabled = validTopic(topic) && !alreadyReserved;
const { t } = useTranslation();
const [error, setError] = useState("");
const [topic, setTopic] = useState(props.topic || "");
const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const allowTopicEdit = !props.topic;
const alreadyReserved =
props.reservations.filter((r) => r.topic === topic).length > 0;
const submitButtonEnabled = validTopic(topic) && !alreadyReserved;
const handleSubmit = async () => {
try {
await accountApi.upsertReservation(topic, everyone);
console.debug(`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`);
} catch (e) {
console.log(`[ReserveAddDialog] Error adding topic reservation.`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else if (e instanceof TopicReservedError) {
setError(t("subscribe_dialog_error_topic_already_reserved"));
return;
} else {
setError(e.message);
return;
}
}
props.onClose();
};
const handleSubmit = async () => {
try {
await accountApi.upsertReservation(topic, everyone);
console.debug(
`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`
);
} catch (e) {
console.log(`[ReserveAddDialog] Error adding topic reservation.`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else if (e instanceof TopicReservedError) {
setError(t("subscribe_dialog_error_topic_already_reserved"));
return;
} else {
setError(e.message);
return;
}
}
props.onClose();
};
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{t("prefs_reservations_dialog_title_add")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("prefs_reservations_dialog_description")}
</DialogContentText>
{allowTopicEdit && <TextField
autoFocus
margin="dense"
id="topic"
label={t("prefs_reservations_dialog_topic_label")}
aria-label={t("prefs_reservations_dialog_topic_label")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
type="url"
fullWidth
variant="standard"
/>}
<ReserveTopicSelect
value={everyone}
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>
);
return (
<Dialog
open={props.open}
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>{t("prefs_reservations_dialog_title_add")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("prefs_reservations_dialog_description")}
</DialogContentText>
{allowTopicEdit && (
<TextField
autoFocus
margin="dense"
id="topic"
label={t("prefs_reservations_dialog_topic_label")}
aria-label={t("prefs_reservations_dialog_topic_label")}
value={topic}
onChange={(ev) => setTopic(ev.target.value)}
type="url"
fullWidth
variant="standard"
/>
)}
<ReserveTopicSelect
value={everyone}
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) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const { t } = useTranslation();
const [error, setError] = useState("");
const [everyone, setEveryone] = useState(
props.reservation?.everyone || Permission.DENY_ALL
);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSubmit = async () => {
try {
await accountApi.upsertReservation(props.reservation.topic, everyone);
console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`);
} catch (e) {
console.log(`[ReserveEditDialog] Error updating topic reservation.`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
}
}
props.onClose();
};
const handleSubmit = async () => {
try {
await accountApi.upsertReservation(props.reservation.topic, everyone);
console.debug(
`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`
);
} catch (e) {
console.log(`[ReserveEditDialog] Error updating topic reservation.`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
}
}
props.onClose();
};
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{t("prefs_reservations_dialog_title_edit")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("prefs_reservations_dialog_description")}
</DialogContentText>
<ReserveTopicSelect
value={everyone}
onChange={setEveryone}
sx={{mt: 1}}
/>
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit}>{t("common_save")}</Button>
</DialogFooter>
</Dialog>
);
return (
<Dialog
open={props.open}
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>{t("prefs_reservations_dialog_title_edit")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("prefs_reservations_dialog_description")}
</DialogContentText>
<ReserveTopicSelect
value={everyone}
onChange={setEveryone}
sx={{ mt: 1 }}
/>
</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) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [deleteMessages, setDeleteMessages] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const { t } = useTranslation();
const [error, setError] = useState("");
const [deleteMessages, setDeleteMessages] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSubmit = async () => {
try {
await accountApi.deleteReservation(props.topic, deleteMessages);
console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`);
} catch (e) {
console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
}
}
props.onClose();
};
const handleSubmit = async () => {
try {
await accountApi.deleteReservation(props.topic, deleteMessages);
console.debug(
`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`
);
} catch (e) {
console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
}
}
props.onClose();
};
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{t("prefs_reservations_dialog_title_delete")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("reservation_delete_dialog_description")}
</DialogContentText>
<FormControl fullWidth variant="standard">
<Select
value={deleteMessages}
onChange={(ev) => setDeleteMessages(ev.target.value)}
sx={{
"& .MuiSelect-select": {
display: 'flex',
alignItems: 'center',
paddingTop: "4px",
paddingBottom: "4px",
}
}}
>
<MenuItem value={false}>
<ListItemIcon><Check/></ListItemIcon>
<ListItemText primary={t("reservation_delete_dialog_action_keep_title")}/>
</MenuItem>
<MenuItem value={true}>
<ListItemIcon><DeleteForever/></ListItemIcon>
<ListItemText primary={t("reservation_delete_dialog_action_delete_title")}/>
</MenuItem>
</Select>
</FormControl>
{!deleteMessages &&
<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>
);
return (
<Dialog
open={props.open}
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>{t("prefs_reservations_dialog_title_delete")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("reservation_delete_dialog_description")}
</DialogContentText>
<FormControl fullWidth variant="standard">
<Select
value={deleteMessages}
onChange={(ev) => setDeleteMessages(ev.target.value)}
sx={{
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
paddingTop: "4px",
paddingBottom: "4px",
},
}}
>
<MenuItem value={false}>
<ListItemIcon>
<Check />
</ListItemIcon>
<ListItemText
primary={t("reservation_delete_dialog_action_keep_title")}
/>
</MenuItem>
<MenuItem value={true}>
<ListItemIcon>
<DeleteForever />
</ListItemIcon>
<ListItemText
primary={t("reservation_delete_dialog_action_delete_title")}
/>
</MenuItem>
</Select>
</FormControl>
{!deleteMessages && (
<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 {Lock, Public} from "@mui/icons-material";
import * as React from "react";
import { Lock, Public } from "@mui/icons-material";
import Box from "@mui/material/Box";
export const PermissionReadWrite = React.forwardRef((props, ref) => {
return <PermissionInternal icon={Public} ref={ref} {...props}/>;
return <PermissionInternal icon={Public} ref={ref} {...props} />;
});
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) => {
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) => {
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 size = props.size ?? "medium";
const Icon = props.icon;
return (
<Box ref={ref} {...props} style={{ position: "relative", display: "inline-flex", verticalAlign: "middle", height: "24px" }}>
<Icon fontSize={size} sx={{ color: "gray" }}/>
{props.text &&
<Box
sx={{
position: "absolute",
right: "-6px",
bottom: "5px",
fontSize: 10,
fontWeight: 600,
color: "gray",
width: "8px",
height: "8px",
marginTop: "3px"
}}
>
{props.text}
</Box>
}
const size = props.size ?? "medium";
const Icon = props.icon;
return (
<Box
ref={ref}
{...props}
style={{
position: "relative",
display: "inline-flex",
verticalAlign: "middle",
height: "24px",
}}
>
<Icon fontSize={size} sx={{ color: "gray" }} />
{props.text && (
<Box
sx={{
position: "absolute",
right: "-6px",
bottom: "5px",
fontSize: 10,
fontWeight: 600,
color: "gray",
width: "8px",
height: "8px",
marginTop: "3px",
}}
>
{props.text}
</Box>
);
)}
</Box>
);
});

View file

@ -1,49 +1,70 @@
import * as React from 'react';
import {FormControl, Select} from "@mui/material";
import {useTranslation} from "react-i18next";
import * as React from "react";
import { FormControl, Select } from "@mui/material";
import { useTranslation } from "react-i18next";
import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
import {Permission} from "../app/AccountApi";
import {
PermissionDenyAll,
PermissionRead,
PermissionReadWrite,
PermissionWrite,
} from "./ReserveIcons";
import { Permission } from "../app/AccountApi";
const ReserveTopicSelect = (props) => {
const { t } = useTranslation();
const sx = props.sx || {};
return (
<FormControl fullWidth variant="standard" sx={sx}>
<Select
value={props.value}
onChange={(ev) => props.onChange(ev.target.value)}
aria-label={t("prefs_reservations_dialog_access_label")}
sx={{
"& .MuiSelect-select": {
display: 'flex',
alignItems: 'center',
paddingTop: "4px",
paddingBottom: "4px",
}
}}
>
<MenuItem value={Permission.DENY_ALL}>
<ListItemIcon><PermissionDenyAll/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_deny_all")}/>
</MenuItem>
<MenuItem value={Permission.READ_ONLY}>
<ListItemIcon><PermissionRead/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_read_only")}/>
</MenuItem>
<MenuItem value={Permission.WRITE_ONLY}>
<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>
);
const { t } = useTranslation();
const sx = props.sx || {};
return (
<FormControl fullWidth variant="standard" sx={sx}>
<Select
value={props.value}
onChange={(ev) => props.onChange(ev.target.value)}
aria-label={t("prefs_reservations_dialog_access_label")}
sx={{
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
paddingTop: "4px",
paddingBottom: "4px",
},
}}
>
<MenuItem value={Permission.DENY_ALL}>
<ListItemIcon>
<PermissionDenyAll />
</ListItemIcon>
<ListItemText
primary={t("prefs_reservations_table_everyone_deny_all")}
/>
</MenuItem>
<MenuItem value={Permission.READ_ONLY}>
<ListItemIcon>
<PermissionRead />
</ListItemIcon>
<ListItemText
primary={t("prefs_reservations_table_everyone_read_only")}
/>
</MenuItem>
<MenuItem value={Permission.WRITE_ONLY}>
<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;

View file

@ -1,158 +1,167 @@
import * as React from 'react';
import {useState} from 'react';
import * as React from "react";
import { useState } from "react";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import routes from "./routes";
import session from "../app/Session";
import Typography from "@mui/material/Typography";
import {NavLink} from "react-router-dom";
import { NavLink } from "react-router-dom";
import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next";
import { useTranslation } from "react-i18next";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import accountApi from "../app/AccountApi";
import {InputAdornment} from "@mui/material";
import { InputAdornment } from "@mui/material";
import IconButton from "@mui/material/IconButton";
import {Visibility, VisibilityOff} from "@mui/icons-material";
import {AccountCreateLimitReachedError, UserExistsError} from "../app/errors";
import { Visibility, VisibilityOff } from "@mui/icons-material";
import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors";
const Signup = () => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const { t } = useTranslation();
const [error, setError] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault();
const user = { username, password };
try {
await accountApi.create(user.username, user.password);
const token = await accountApi.login(user);
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
session.store(user.username, token);
window.location.href = routes.app;
} catch (e) {
console.log(`[Signup] Signup for user ${user.username} failed`, e);
if (e instanceof UserExistsError) {
setError(t("signup_error_username_taken", { username: e.username }));
} else if ((e instanceof AccountCreateLimitReachedError)) {
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>
);
const handleSubmit = async (event) => {
event.preventDefault();
const user = { username, password };
try {
await accountApi.create(user.username, user.password);
const token = await accountApi.login(user);
console.log(
`[Signup] User signup for user ${user.username} successful, token is ${token}`
);
session.store(user.username, token);
window.location.href = routes.app;
} catch (e) {
console.log(`[Signup] Signup for user ${user.username} failed`, e);
if (e instanceof UserExistsError) {
setError(t("signup_error_username_taken", { username: e.username }));
} else if (e instanceof AccountCreateLimitReachedError) {
setError(t("signup_error_creation_limit_reached"));
} else {
setError(e.message);
}
}
};
if (!config.enable_signup) {
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>
<AvatarBox>
<Typography sx={{ typography: "h6" }}>
{t("signup_disabled")}
</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;

View file

@ -1,313 +1,388 @@
import * as React from 'react';
import {useContext, useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import {Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery} from "@mui/material";
import * as React from "react";
import { useContext, useState } from "react";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import {
Autocomplete,
Checkbox,
FormControlLabel,
FormGroup,
useMediaQuery,
} from "@mui/material";
import theme from "./theme";
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 subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller";
import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
import { useTranslation } from "react-i18next";
import session from "../app/Session";
import routes from "./routes";
import accountApi, {Permission, Role} from "../app/AccountApi";
import accountApi, { Permission, Role } from "../app/AccountApi";
import ReserveTopicSelect from "./ReserveTopicSelect";
import {AccountContext} from "./App";
import {TopicReservedError, UnauthorizedError} from "../app/errors";
import {ReserveLimitChip} from "./SubscriptionPopup";
import { AccountContext } from "./App";
import { TopicReservedError, UnauthorizedError } from "../app/errors";
import { ReserveLimitChip } from "./SubscriptionPopup";
const publicBaseUrl = "https://ntfy.sh";
const SubscribeDialog = (props) => {
const [baseUrl, setBaseUrl] = useState("");
const [topic, setTopic] = useState("");
const [showLoginPage, setShowLoginPage] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const [baseUrl, setBaseUrl] = useState("");
const [topic, setTopic] = useState("");
const [showLoginPage, setShowLoginPage] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSuccess = async () => {
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
const actualBaseUrl = (baseUrl) ? baseUrl : config.base_url;
const subscription = await subscribeTopic(actualBaseUrl, topic);
poller.pollInBackground(subscription); // Dangle!
props.onSuccess(subscription);
}
const handleSuccess = async () => {
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
const actualBaseUrl = baseUrl ? baseUrl : config.base_url;
const subscription = await subscribeTopic(actualBaseUrl, topic);
poller.pollInBackground(subscription); // Dangle!
props.onSuccess(subscription);
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
{!showLoginPage && <SubscribePage
baseUrl={baseUrl}
setBaseUrl={setBaseUrl}
topic={topic}
setTopic={setTopic}
subscriptions={props.subscriptions}
onCancel={props.onCancel}
onNeedsLogin={() => setShowLoginPage(true)}
onSuccess={handleSuccess}
/>}
{showLoginPage && <LoginPage
baseUrl={baseUrl}
topic={topic}
onBack={() => setShowLoginPage(false)}
onSuccess={handleSuccess}
/>}
</Dialog>
);
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
{!showLoginPage && (
<SubscribePage
baseUrl={baseUrl}
setBaseUrl={setBaseUrl}
topic={topic}
setTopic={setTopic}
subscriptions={props.subscriptions}
onCancel={props.onCancel}
onNeedsLogin={() => setShowLoginPage(true)}
onSuccess={handleSuccess}
/>
)}
{showLoginPage && (
<LoginPage
baseUrl={baseUrl}
topic={topic}
onBack={() => setShowLoginPage(false)}
onSuccess={handleSuccess}
/>
)}
</Dialog>
);
};
const SubscribePage = (props) => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const [error, setError] = useState("");
const [reserveTopicVisible, setReserveTopicVisible] = useState(false);
const [anotherServerVisible, setAnotherServerVisible] = useState(false);
const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const baseUrl = (anotherServerVisible) ? props.baseUrl : config.base_url;
const topic = props.topic;
const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic));
const existingBaseUrls = Array
.from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)]))
.filter(s => s !== config.base_url);
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 { t } = useTranslation();
const { account } = useContext(AccountContext);
const [error, setError] = useState("");
const [reserveTopicVisible, setReserveTopicVisible] = useState(false);
const [anotherServerVisible, setAnotherServerVisible] = useState(false);
const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url;
const topic = props.topic;
const existingTopicUrls = props.subscriptions.map((s) =>
topicUrl(s.baseUrl, s.topic)
);
const existingBaseUrls = Array.from(
new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])
).filter((s) => s !== config.base_url);
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 user = await userManager.get(baseUrl); // May be undefined
const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous");
const handleSubscribe = async () => {
const user = await userManager.get(baseUrl); // May be undefined
const username = user
? user.username
: t("subscribe_dialog_error_user_anonymous");
// Check read access to topic
const success = await api.topicAuth(baseUrl, topic, user);
if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
if (user) {
setError(t("subscribe_dialog_error_user_not_authorized", { username: username }));
return;
} else {
props.onNeedsLogin();
return;
}
// Check read access to topic
const success = await api.topicAuth(baseUrl, topic, user);
if (!success) {
console.log(
`[SubscribeDialog] Login to ${topicUrl(
baseUrl,
topic
)} failed for user ${username}`
);
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)
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;
}
}
}
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>
</>
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>
</>
);
};
const LoginPage = (props) => {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const baseUrl = (props.baseUrl) ? props.baseUrl : config.base_url;
const topic = props.topic;
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const baseUrl = props.baseUrl ? props.baseUrl : config.base_url;
const topic = props.topic;
const handleLogin = async () => {
const user = {baseUrl, username, password};
const success = await api.topicAuth(baseUrl, topic, user);
if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
setError(t("subscribe_dialog_error_user_not_authorized", { username: username }));
return;
}
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
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>
</>
const handleLogin = async () => {
const user = { baseUrl, username, password };
const success = await api.topicAuth(baseUrl, topic, user);
if (!success) {
console.log(
`[SubscribeDialog] Login to ${topicUrl(
baseUrl,
topic
)} failed for user ${username}`
);
setError(
t("subscribe_dialog_error_user_not_authorized", { username: username })
);
return;
}
console.log(
`[SubscribeDialog] Successful login to ${topicUrl(
baseUrl,
topic
)} for user ${username}`
);
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) => {
const subscription = await subscriptionManager.add(baseUrl, topic);
if (session.exists()) {
try {
await accountApi.addSubscription(baseUrl, topic);
} catch (e) {
console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
const subscription = await subscriptionManager.add(baseUrl, topic);
if (session.exists()) {
try {
await accountApi.addSubscription(baseUrl, topic);
} catch (e) {
console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
return subscription;
}
return subscription;
};
export default SubscribeDialog;

View file

@ -1,292 +1,393 @@
import * as React from 'react';
import {useContext, useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import {Chip, InputAdornment, Portal, Snackbar, useMediaQuery} from "@mui/material";
import * as React from "react";
import { useContext, useState } from "react";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import {
Chip,
InputAdornment,
Portal,
Snackbar,
useMediaQuery,
} from "@mui/material";
import theme from "./theme";
import subscriptionManager from "../app/SubscriptionManager";
import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
import accountApi, {Role} from "../app/AccountApi";
import { useTranslation } from "react-i18next";
import accountApi, { Role } from "../app/AccountApi";
import session from "../app/Session";
import routes from "./routes";
import MenuItem from "@mui/material/MenuItem";
import PopupMenu from "./PopupMenu";
import {formatShortDateTime, shuffle} from "../app/utils";
import { formatShortDateTime, shuffle } from "../app/utils";
import api from "../app/Api";
import {useNavigate} from "react-router-dom";
import { useNavigate } from "react-router-dom";
import IconButton from "@mui/material/IconButton";
import {Clear} from "@mui/icons-material";
import {AccountContext} from "./App";
import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs";
import {UnauthorizedError} from "../app/errors";
import { Clear } from "@mui/icons-material";
import { AccountContext } from "./App";
import {
ReserveAddDialog,
ReserveDeleteDialog,
ReserveEditDialog,
} from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors";
export const SubscriptionPopup = (props) => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const navigate = useNavigate();
const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false);
const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false);
const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false);
const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false);
const [showPublishError, setShowPublishError] = useState(false);
const subscription = props.subscription;
const placement = props.placement ?? "left";
const reservations = account?.reservations || [];
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const navigate = useNavigate();
const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false);
const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false);
const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false);
const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false);
const [showPublishError, setShowPublishError] = useState(false);
const subscription = props.subscription;
const placement = props.placement ?? "left";
const reservations = account?.reservations || [];
const showReservationAdd = 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 showReservationAdd =
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 () => {
setDisplayNameDialogOpen(true);
const handleChangeDisplayName = async () => {
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 () => {
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 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>
</>
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>
</>
);
};
const DisplayNameDialog = (props) => {
const { t } = useTranslation();
const subscription = props.subscription;
const [error, setError] = useState("");
const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const { t } = useTranslation();
const subscription = props.subscription;
const [error, setError] = useState("");
const [displayName, setDisplayName] = useState(
subscription.displayName ?? ""
);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSave = async () => {
await subscriptionManager.setDisplayName(subscription.id, displayName);
if (session.exists() && !subscription.internal) {
try {
console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`);
await accountApi.updateSubscription(subscription.baseUrl, subscription.topic, { display_name: displayName });
} catch (e) {
console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
}
}
const handleSave = async () => {
await subscriptionManager.setDisplayName(subscription.id, displayName);
if (session.exists() && !subscription.internal) {
try {
console.log(
`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`
);
await accountApi.updateSubscription(
subscription.baseUrl,
subscription.topic,
{ display_name: displayName }
);
} 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 (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{t("display_name_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("display_name_dialog_description")}
</DialogContentText>
<TextField
autoFocus
placeholder={t("display_name_dialog_placeholder")}
value={displayName}
onChange={ev => setDisplayName(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
maxLength: 64,
"aria-label": t("display_name_dialog_placeholder")
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setDisplayName("")} edge="end">
<Clear/>
</IconButton>
</InputAdornment>
)
}}
/>
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSave}>{t("common_save")}</Button>
</DialogFooter>
</Dialog>
);
return (
<Dialog
open={props.open}
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>{t("display_name_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("display_name_dialog_description")}
</DialogContentText>
<TextField
autoFocus
placeholder={t("display_name_dialog_placeholder")}
value={displayName}
onChange={(ev) => setDisplayName(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
maxLength: 64,
"aria-label": t("display_name_dialog_placeholder"),
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setDisplayName("")} edge="end">
<Clear />
</IconButton>
</InputAdornment>
),
}}
/>
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSave}>{t("common_save")}</Button>
</DialogFooter>
</Dialog>
);
};
export const ReserveLimitChip = () => {
const { account } = useContext(AccountContext);
if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) {
return <></>;
} else if (config.enable_payments) {
return (account?.limits.reservations > 0) ? <LimitReachedChip/> : <ProChip/>;
} else if (account) {
return <LimitReachedChip/>;
}
const { account } = useContext(AccountContext);
if (
account?.role === Role.ADMIN ||
account?.stats.reservations_remaining > 0
) {
return <></>;
} else if (config.enable_payments) {
return account?.limits.reservations > 0 ? (
<LimitReachedChip />
) : (
<ProChip />
);
} else if (account) {
return <LimitReachedChip />;
}
return <></>;
};
const LimitReachedChip = () => {
const { t } = useTranslation();
return (
<Chip
label={t("action_bar_reservation_limit_reached")}
variant="outlined"
color="primary"
sx={{ opacity: 0.8, borderWidth: "2px", height: "24px", marginLeft: "5px" }}
/>
);
const { t } = useTranslation();
return (
<Chip
label={t("action_bar_reservation_limit_reached")}
variant="outlined"
color="primary"
sx={{
opacity: 0.8,
borderWidth: "2px",
height: "24px",
marginLeft: "5px",
}}
/>
);
};
export const ProChip = () => {
const { t } = useTranslation();
return (
<Chip
label={"ntfy Pro"}
variant="outlined"
color="primary"
sx={{ opacity: 0.8, fontWeight: "bold", borderWidth: "2px", height: "24px", marginLeft: "5px" }}
/>
);
const { t } = useTranslation();
return (
<Chip
label={"ntfy Pro"}
variant="outlined"
color="primary"
sx={{
opacity: 0.8,
fontWeight: "bold",
borderWidth: "2px",
height: "24px",
marginLeft: "5px",
}}
/>
);
};

View file

@ -1,367 +1,500 @@
import * as React from 'react';
import {useContext, useEffect, useState} from 'react';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import {Alert, CardActionArea, CardContent, Chip, Link, ListItem, Switch, useMediaQuery} from "@mui/material";
import * as React from "react";
import { useContext, useEffect, useState } from "react";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import {
Alert,
CardActionArea,
CardContent,
Chip,
Link,
ListItem,
Switch,
useMediaQuery,
} from "@mui/material";
import theme from "./theme";
import Button from "@mui/material/Button";
import accountApi, {SubscriptionInterval} from "../app/AccountApi";
import accountApi, { SubscriptionInterval } from "../app/AccountApi";
import session from "../app/Session";
import routes from "./routes";
import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography";
import {AccountContext} from "./App";
import {formatBytes, formatNumber, formatPrice, formatShortDate} from "../app/utils";
import {Trans, useTranslation} from "react-i18next";
import { AccountContext } from "./App";
import {
formatBytes,
formatNumber,
formatPrice,
formatShortDate,
} from "../app/utils";
import { Trans, useTranslation } from "react-i18next";
import List from "@mui/material/List";
import {Check, Close} from "@mui/icons-material";
import { Check, Close } from "@mui/icons-material";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Box from "@mui/material/Box";
import {NavLink} from "react-router-dom";
import {UnauthorizedError} from "../app/errors";
import { NavLink } from "react-router-dom";
import { UnauthorizedError } from "../app/errors";
import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions";
const UpgradeDialog = (props) => {
const { t } = useTranslation();
const { account } = useContext(AccountContext); // May be undefined!
const [error, setError] = useState("");
const [tiers, setTiers] = useState(null);
const [interval, setInterval] = useState(account?.billing?.interval || SubscriptionInterval.YEAR);
const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined
const [loading, setLoading] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const { t } = useTranslation();
const { account } = useContext(AccountContext); // May be undefined!
const [error, setError] = useState("");
const [tiers, setTiers] = useState(null);
const [interval, setInterval] = useState(
account?.billing?.interval || SubscriptionInterval.YEAR
);
const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined
const [loading, setLoading] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
useEffect(() => {
const fetchTiers = async () => {
setTiers(await accountApi.billingTiers());
}
fetchTiers(); // Dangle
}, []);
useEffect(() => {
const fetchTiers = async () => {
setTiers(await accountApi.billingTiers());
};
fetchTiers(); // Dangle
}, []);
if (!tiers) {
return <></>;
if (!tiers) {
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;
}
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;
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);
}
};
// 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 {
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>
// 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>
);
};
const TierCard = (props) => {
const { t } = useTranslation();
const tier = props.tier;
const { t } = useTranslation();
const tier = props.tier;
let cardStyle, labelStyle, labelText;
if (props.selected) {
cardStyle = { background: "#eee", border: "3px solid #338574" };
labelStyle = { background: "#338574", color: "white" };
labelText = t("account_upgrade_dialog_tier_selected_label");
} else if (props.current) {
cardStyle = { border: "3px solid #eee" };
labelStyle = { background: "#eee", color: "black" };
labelText = t("account_upgrade_dialog_tier_current_label");
} else {
cardStyle = { border: "3px solid transparent" };
}
let cardStyle, labelStyle, labelText;
if (props.selected) {
cardStyle = { background: "#eee", border: "3px solid #338574" };
labelStyle = { background: "#338574", color: "white" };
labelText = t("account_upgrade_dialog_tier_selected_label");
} else if (props.current) {
cardStyle = { border: "3px solid #eee" };
labelStyle = { background: "#eee", color: "black" };
labelText = t("account_upgrade_dialog_tier_current_label");
} else {
cardStyle = { border: "3px solid transparent" };
}
let monthlyPrice;
if (!tier.prices) {
monthlyPrice = 0;
} else if (props.interval === SubscriptionInterval.YEAR) {
monthlyPrice = tier.prices.year/12;
} else if (props.interval === SubscriptionInterval.MONTH) {
monthlyPrice = tier.prices.month;
}
let monthlyPrice;
if (!tier.prices) {
monthlyPrice = 0;
} else if (props.interval === SubscriptionInterval.YEAR) {
monthlyPrice = tier.prices.year / 12;
} else if (props.interval === SubscriptionInterval.MONTH) {
monthlyPrice = tier.prices.month;
}
return (
<Box sx={{
m: "7px",
minWidth: "240px",
flexGrow: 1,
flexShrink: 1,
flexBasis: 0,
borderRadius: "5px",
"&:first-of-type": { ml: 0 },
"&:last-of-type": { mr: 0 },
...cardStyle
}}>
<Card sx={{ height: "100%" }}>
<CardActionArea sx={{ height: "100%" }}>
<CardContent onClick={props.onClick} sx={{ height: "100%" }}>
{labelStyle &&
<div style={{
position: "absolute",
top: "0",
right: "15px",
padding: "2px 10px",
borderRadius: "3px",
...labelStyle
}}>{labelText}</div>
}
<Typography variant="subtitle1" component="div">
{tier.name || t("account_basics_tier_free")}
</Typography>
<div>
<Typography component="span" variant="h4" sx={{ fontWeight: 500, marginRight: "3px" }}>{formatPrice(monthlyPrice)}</Typography>
{monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}</>}
</div>
<List dense>
{tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>}
<Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature>
<Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature>
{tier.limits.calls > 0 && <Feature>{t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}</Feature>}
<Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature>
{tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
{tier.limits.calls === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_calls")}</NoFeature>}
</List>
{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>
);
}
return (
<Box
sx={{
m: "7px",
minWidth: "240px",
flexGrow: 1,
flexShrink: 1,
flexBasis: 0,
borderRadius: "5px",
"&:first-of-type": { ml: 0 },
"&:last-of-type": { mr: 0 },
...cardStyle,
}}
>
<Card sx={{ height: "100%" }}>
<CardActionArea sx={{ height: "100%" }}>
<CardContent onClick={props.onClick} sx={{ height: "100%" }}>
{labelStyle && (
<div
style={{
position: "absolute",
top: "0",
right: "15px",
padding: "2px 10px",
borderRadius: "3px",
...labelStyle,
}}
>
{labelText}
</div>
)}
<Typography variant="subtitle1" component="div">
{tier.name || t("account_basics_tier_free")}
</Typography>
<div>
<Typography
component="span"
variant="h4"
sx={{ fontWeight: 500, marginRight: "3px" }}
>
{formatPrice(monthlyPrice)}
</Typography>
{monthlyPrice > 0 && (
<>/ {t("account_upgrade_dialog_tier_price_per_month")}</>
)}
</div>
<List dense>
{tier.limits.reservations > 0 && (
<Feature>
{t("account_upgrade_dialog_tier_features_reservations", {
reservations: tier.limits.reservations,
count: tier.limits.reservations,
})}
</Feature>
)}
<Feature>
{t("account_upgrade_dialog_tier_features_messages", {
messages: formatNumber(tier.limits.messages),
count: tier.limits.messages,
})}
</Feature>
<Feature>
{t("account_upgrade_dialog_tier_features_emails", {
emails: formatNumber(tier.limits.emails),
count: tier.limits.emails,
})}
</Feature>
{tier.limits.calls > 0 && (
<Feature>
{t("account_upgrade_dialog_tier_features_calls", {
calls: formatNumber(tier.limits.calls),
count: tier.limits.calls,
})}
</Feature>
)}
<Feature>
{t(
"account_upgrade_dialog_tier_features_attachment_file_size",
{ filesize: formatBytes(tier.limits.attachment_file_size, 0) }
)}
</Feature>
{tier.limits.reservations === 0 && (
<NoFeature>
{t("account_upgrade_dialog_tier_features_no_reservations")}
</NoFeature>
)}
{tier.limits.calls === 0 && (
<NoFeature>
{t("account_upgrade_dialog_tier_features_no_calls")}
</NoFeature>
)}
</List>
{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) => {
return <FeatureItem feature={true}>{props.children}</FeatureItem>;
}
return <FeatureItem feature={true}>{props.children}</FeatureItem>;
};
const NoFeature = (props) => {
return <FeatureItem feature={false}>{props.children}</FeatureItem>;
}
return <FeatureItem feature={false}>{props.children}</FeatureItem>;
};
const FeatureItem = (props) => {
return (
<ListItem disableGutters sx={{m: 0, p: 0}}>
<ListItemIcon sx={{minWidth: "24px"}}>
{props.feature && <Check fontSize="small" sx={{ color: "#338574" }}/>}
{!props.feature && <Close fontSize="small" sx={{ color: "gray" }}/>}
</ListItemIcon>
<ListItemText
sx={{mt: "2px", mb: "2px"}}
primary={
<Typography variant="body1">
{props.children}
</Typography>
}
/>
</ListItem>
);
return (
<ListItem disableGutters sx={{ m: 0, p: 0 }}>
<ListItemIcon sx={{ minWidth: "24px" }}>
{props.feature && <Check fontSize="small" sx={{ color: "#338574" }} />}
{!props.feature && <Close fontSize="small" sx={{ color: "gray" }} />}
</ListItemIcon>
<ListItemText
sx={{ mt: "2px", mb: "2px" }}
primary={<Typography variant="body1">{props.children}</Typography>}
/>
</ListItem>
);
};
const Action = {
REDIRECT_SIGNUP: 1,
CREATE_SUBSCRIPTION: 2,
UPDATE_SUBSCRIPTION: 3,
CANCEL_SUBSCRIPTION: 4
REDIRECT_SIGNUP: 1,
CREATE_SUBSCRIPTION: 2,
UPDATE_SUBSCRIPTION: 3,
CANCEL_SUBSCRIPTION: 4,
};
const Banner = {
CANCEL_WARNING: 1,
PRORATION_INFO: 2,
RESERVATIONS_WARNING: 3
CANCEL_WARNING: 1,
PRORATION_INFO: 2,
RESERVATIONS_WARNING: 3,
};
export default UpgradeDialog;

View file

@ -1,7 +1,7 @@
import {useNavigate, useParams} from "react-router-dom";
import {useEffect, useState} from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useState } from "react";
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 routes from "./routes";
import connectionManager from "../app/ConnectionManager";
@ -9,7 +9,7 @@ import poller from "../app/Poller";
import pruner from "../app/Pruner";
import session from "../app/Session";
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
@ -17,65 +17,82 @@ import {UnauthorizedError} from "../app/errors";
* to the connection being re-established).
*/
export const useConnectionListeners = (account, subscriptions, users) => {
const navigate = useNavigate();
const navigate = useNavigate();
// Register listeners for incoming messages, and connection state changes
useEffect(() => {
const handleMessage = async (subscriptionId, message) => {
const subscription = await subscriptionManager.get(subscriptionId);
if (subscription.internal) {
await handleInternalMessage(message);
} else {
await handleNotification(subscriptionId, message);
}
};
const handleInternalMessage = async (message) => {
console.log(`[ConnectionListener] Received message on sync topic`, message.message);
try {
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;
// Register listeners for incoming messages, and connection state changes
useEffect(
() => {
const handleMessage = async (subscriptionId, message) => {
const subscription = await subscriptionManager.get(subscriptionId);
if (subscription.internal) {
await handleInternalMessage(message);
} else {
await handleNotification(subscriptionId, message);
}
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]);
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
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.
*/
export const useAutoSubscribe = (subscriptions, selected) => {
const [hasRun, setHasRun] = useState(false);
const params = useParams();
const [hasRun, setHasRun] = useState(false);
const params = useParams();
useEffect(() => {
const loaded = subscriptions !== null && subscriptions !== undefined;
if (!loaded || hasRun) {
return;
useEffect(() => {
const loaded = subscriptions !== null && subscriptions !== undefined;
if (!loaded || hasRun) {
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);
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);
}
}
}
poller.pollInBackground(subscription); // Dangle!
})();
}
}, [params, subscriptions, selected, hasRun]);
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.
*/
export const useBackgroundProcesses = () => {
useEffect(() => {
poller.startWorker();
pruner.startWorker();
accountApi.startWorker();
}, []);
}
useEffect(() => {
poller.startWorker();
pruner.startWorker();
accountApi.startWorker();
}, []);
};
export const useAccountListener = (setAccount) => {
useEffect(() => {
accountApi.registerListener(setAccount);
accountApi.sync(); // Dangle
return () => {
accountApi.resetListener();
}
}, []);
}
useEffect(() => {
accountApi.registerListener(setAccount);
accountApi.sync(); // Dangle
return () => {
accountApi.resetListener();
};
}, []);
};

View file

@ -1,7 +1,7 @@
import i18n from 'i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import i18n from "i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
// Translations using i18next
// - 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
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
debug: true,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
backend: {
loadPath: '/static/langs/{{lng}}.json',
}
});
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en",
debug: true,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
backend: {
loadPath: "/static/langs/{{lng}}.json",
},
});
export default i18n;

View file

@ -1,20 +1,20 @@
import config from "../app/config";
import {shortUrl} from "../app/utils";
import { shortUrl } from "../app/utils";
const routes = {
login: "/login",
signup: "/signup",
app: config.app_root,
account: "/account",
settings: "/settings",
subscription: "/:topic",
subscriptionExternal: "/:baseUrl/:topic",
forSubscription: (subscription) => {
if (subscription.baseUrl !== config.base_url) {
return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;
}
return `/${subscription.topic}`;
login: "/login",
signup: "/signup",
app: config.app_root,
account: "/account",
settings: "/settings",
subscription: "/:topic",
subscriptionExternal: "/:baseUrl/:topic",
forSubscription: (subscription) => {
if (subscription.baseUrl !== config.base_url) {
return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;
}
return `/${subscription.topic}`;
},
};
export default routes;

View file

@ -1,7 +1,7 @@
import Typography from "@mui/material/Typography";
import theme from "./theme";
import Container from "@mui/material/Container";
import {Backdrop, styled} from "@mui/material";
import { Backdrop, styled } from "@mui/material";
export const Paragraph = styled(Typography)({
paddingTop: 8,
@ -9,14 +9,14 @@ export const Paragraph = styled(Typography)({
});
export const VerticallyCenteredContainer = styled(Container)({
display: 'flex',
display: "flex",
flexGrow: 1,
flexDirection: 'column',
justifyContent: 'center',
alignContent: 'center',
color: theme.palette.text.primary
flexDirection: "column",
justifyContent: "center",
alignContent: "center",
color: theme.palette.text.primary,
});
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 { createTheme } from '@mui/material/styles';
import { red } from "@mui/material/colors";
import { createTheme } from "@mui/material/styles";
const theme = createTheme({
palette: {
primary: {
main: '#338574',
main: "#338574",
},
secondary: {
main: '#6cead0',
main: "#6cead0",
},
error: {
main: red.A400,
@ -17,19 +17,19 @@ const theme = createTheme({
MuiListItemIcon: {
styleOverrides: {
root: {
minWidth: '36px',
minWidth: "36px",
},
},
},
MuiCardContent: {
styleOverrides: {
root: {
':last-child': {
paddingBottom: '16px'
}
}
}
}
":last-child": {
paddingBottom: "16px",
},
},
},
},
},
});

View file

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