Line width

pull/747/head
binwiederhier 2023-05-23 19:29:47 -04:00
parent 2e27f58963
commit ca5d736a71
33 changed files with 521 additions and 2033 deletions

View File

@ -1,2 +1,2 @@
build/ build/
public/static/langs/ public/static/langs/

View File

@ -43,5 +43,8 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"prettier": {
"printWidth": 160
} }
} }

View File

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

View File

@ -5,10 +5,7 @@
<title>ntfy web</title> <title>ntfy web</title>
<!-- Mobile view --> <!-- Mobile view -->
<meta <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
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 http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="HandheldFriendly" content="true" /> <meta name="HandheldFriendly" content="true" />
@ -18,11 +15,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" /> <meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
<!-- Favicon, see favicon.io --> <!-- Favicon, see favicon.io -->
<link <link rel="icon" type="image/png" href="%PUBLIC_URL%/static/images/favicon.ico" />
rel="icon"
type="image/png"
href="%PUBLIC_URL%/static/images/favicon.ico"
/>
<!-- Previews in Google, Slack, WhatsApp, etc. --> <!-- Previews in Google, Slack, WhatsApp, etc. -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
@ -40,23 +33,13 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<!-- Style overrides & fonts --> <!-- Style overrides & fonts -->
<link <link rel="stylesheet" href="%PUBLIC_URL%/static/css/app.css" type="text/css" />
rel="stylesheet" <link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css" />
href="%PUBLIC_URL%/static/css/app.css"
type="text/css"
/>
<link
rel="stylesheet"
href="%PUBLIC_URL%/static/css/fonts.css"
type="text/css"
/>
</head> </head>
<body> <body>
<noscript> <noscript>
ntfy web requires JavaScript, but you can also use the 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/cli/">CLI</a> or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to subscribe.
<a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to
subscribe.
</noscript> </noscript>
<div id="root"></div> <div id="root"></div>
<script src="%PUBLIC_URL%/config.js"></script> <script src="%PUBLIC_URL%/config.js"></script>

View File

@ -56,9 +56,7 @@ class AccountApi {
async logout() { async logout() {
const url = accountTokenUrl(config.base_url); const url = accountTokenUrl(config.base_url);
console.log( console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`);
`[AccountApi] Logging out from ${url} using token ${session.token()}`
);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "DELETE", method: "DELETE",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
@ -227,9 +225,7 @@ class AccountApi {
async upsertReservation(topic, everyone) { async upsertReservation(topic, everyone) {
const url = accountReservationUrl(config.base_url); const url = accountReservationUrl(config.base_url);
console.log( console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`);
`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`
);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
@ -264,16 +260,12 @@ class AccountApi {
} }
async createBillingSubscription(tier, interval) { async createBillingSubscription(tier, interval) {
console.log( console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`);
`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`
);
return await this.upsertBillingSubscription("POST", tier, interval); return await this.upsertBillingSubscription("POST", tier, interval);
} }
async updateBillingSubscription(tier, interval) { async updateBillingSubscription(tier, interval) {
console.log( console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`);
`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`
);
return await this.upsertBillingSubscription("PUT", tier, interval); return await this.upsertBillingSubscription("PUT", tier, interval);
} }
@ -324,9 +316,7 @@ class AccountApi {
async addPhoneNumber(phoneNumber, code) { async addPhoneNumber(phoneNumber, code) {
const url = accountPhoneUrl(config.base_url); const url = accountPhoneUrl(config.base_url);
console.log( console.log(`[AccountApi] Adding phone number with verification code ${url}`);
`[AccountApi] Adding phone number with verification code ${url}`
);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "PUT", method: "PUT",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
@ -371,10 +361,7 @@ class AccountApi {
} }
} }
if (account.subscriptions) { if (account.subscriptions) {
await subscriptionManager.syncFromRemote( await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations);
account.subscriptions,
account.reservations
);
} }
return account; return account;
} catch (e) { } catch (e) {

View File

@ -1,12 +1,4 @@
import { import { fetchLinesIterator, maybeWithAuth, topicShortUrl, topicUrl, topicUrlAuth, topicUrlJsonPoll, topicUrlJsonPollWithSince } from "./utils";
fetchLinesIterator,
maybeWithAuth,
topicShortUrl,
topicUrl,
topicUrlAuth,
topicUrlJsonPoll,
topicUrlJsonPollWithSince,
} from "./utils";
import userManager from "./UserManager"; import userManager from "./UserManager";
import { fetchOrThrow } from "./errors"; import { fetchOrThrow } from "./errors";
@ -14,9 +6,7 @@ class Api {
async poll(baseUrl, topic, since) { async poll(baseUrl, topic, since) {
const user = await userManager.get(baseUrl); const user = await userManager.get(baseUrl);
const shortUrl = topicShortUrl(baseUrl, topic); const shortUrl = topicShortUrl(baseUrl, topic);
const url = since const url = since ? topicUrlJsonPollWithSince(baseUrl, topic, since) : topicUrlJsonPoll(baseUrl, topic);
? topicUrlJsonPollWithSince(baseUrl, topic, since)
: topicUrlJsonPoll(baseUrl, topic);
const messages = []; const messages = [];
const headers = maybeWithAuth({}, user); const headers = maybeWithAuth({}, user);
console.log(`[Api] Polling ${url}`); console.log(`[Api] Polling ${url}`);
@ -73,17 +63,11 @@ class Api {
xhr.upload.addEventListener("progress", onProgress); xhr.upload.addEventListener("progress", onProgress);
xhr.addEventListener("readystatechange", () => { xhr.addEventListener("readystatechange", () => {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) { if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
console.log( console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response);
`[Api] Publish successful (HTTP ${xhr.status})`,
xhr.response
);
resolve(xhr.response); resolve(xhr.response);
} else if (xhr.readyState === 4) { } else if (xhr.readyState === 4) {
// Firefox bug; see description above! // Firefox bug; see description above!
console.log( console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText);
`[Api] Publish failed (HTTP ${xhr.status})`,
xhr.responseText
);
let errorText; let errorText;
try { try {
const error = JSON.parse(xhr.responseText); const error = JSON.parse(xhr.responseText);

View File

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

View File

@ -49,12 +49,8 @@ class ConnectionManager {
return { ...s, user, connectionId }; return { ...s, user, connectionId };
}) })
); );
const targetIds = subscriptionsWithUsersAndConnectionId.map( const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId);
(s) => s.connectionId const deletedIds = Array.from(this.connections.keys()).filter((id) => !targetIds.includes(id));
);
const deletedIds = Array.from(this.connections.keys()).filter(
(id) => !targetIds.includes(id)
);
// Create and add new connections // Create and add new connections
subscriptionsWithUsersAndConnectionId.forEach((subscription) => { subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
@ -73,15 +69,12 @@ class ConnectionManager {
topic, topic,
user, user,
since, since,
(subscriptionId, notification) => (subscriptionId, notification) => this.notificationReceived(subscriptionId, notification),
this.notificationReceived(subscriptionId, notification),
(subscriptionId, state) => this.stateChanged(subscriptionId, state) (subscriptionId, state) => this.stateChanged(subscriptionId, state)
); );
this.connections.set(connectionId, connection); this.connections.set(connectionId, connection);
console.log( console.log(
`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${ `[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})`
user ? user.username : "anonymous"
})`
); );
connection.start(); connection.start();
} }
@ -101,10 +94,7 @@ class ConnectionManager {
try { try {
this.stateListener(subscriptionId, state); this.stateListener(subscriptionId, state);
} catch (e) { } catch (e) {
console.error( console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e);
`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`,
e
);
} }
} }
} }
@ -114,23 +104,14 @@ class ConnectionManager {
try { try {
this.messageListener(subscriptionId, notification); this.messageListener(subscriptionId, notification);
} catch (e) { } catch (e) {
console.error( console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e);
`[ConnectionManager] Error handling notification for ${subscriptionId}`,
e
);
} }
} }
} }
} }
const makeConnectionId = async (subscription, user) => { const makeConnectionId = async (subscription, user) => {
return user return user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`);
? hashCode(
`${subscription.id}|${user.username}|${user.password ?? ""}|${
user.token ?? ""
}`
)
: hashCode(`${subscription.id}`);
}; };
const connectionManager = new ConnectionManager(); const connectionManager = new ConnectionManager();

View File

@ -1,11 +1,4 @@
import { import { formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl } from "./utils";
formatMessage,
formatTitleWithDefault,
openUrl,
playSound,
topicDisplayName,
topicShortUrl,
} from "./utils";
import prefs from "./Prefs"; import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager"; import subscriptionManager from "./SubscriptionManager";
import logo from "../img/ntfy.png"; import logo from "../img/ntfy.png";
@ -30,9 +23,7 @@ class Notifier {
const title = formatTitleWithDefault(notification, displayName); const title = formatTitleWithDefault(notification, displayName);
// Show notification // Show notification
console.log( console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`
);
const n = new Notification(title, { const n = new Notification(title, {
body: message, body: message,
icon: logo, icon: logo,
@ -96,11 +87,7 @@ class Notifier {
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
*/ */
contextSupported() { contextSupported() {
return ( return location.protocol === "https:" || location.hostname.match("^127.") || location.hostname === "localhost";
location.protocol === "https:" ||
location.hostname.match("^127.") ||
location.hostname === "localhost"
);
} }
} }

View File

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

View File

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

View File

@ -7,9 +7,7 @@ class SubscriptionManager {
const subscriptions = await db.subscriptions.toArray(); const subscriptions = await db.subscriptions.toArray();
await Promise.all( await Promise.all(
subscriptions.map(async (s) => { subscriptions.map(async (s) => {
s.new = await db.notifications s.new = await db.notifications.where({ subscriptionId: s.id, new: 1 }).count();
.where({ subscriptionId: s.id, new: 1 })
.count();
}) })
); );
return subscriptions; return subscriptions;
@ -38,20 +36,14 @@ class SubscriptionManager {
} }
async syncFromRemote(remoteSubscriptions, remoteReservations) { async syncFromRemote(remoteSubscriptions, remoteReservations) {
console.log( console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
`[SubscriptionManager] Syncing subscriptions from remote`,
remoteSubscriptions
);
// Add remote subscriptions // Add remote subscriptions
let remoteIds = []; // = topicUrl(baseUrl, topic) let remoteIds = []; // = topicUrl(baseUrl, topic)
for (let i = 0; i < remoteSubscriptions.length; i++) { for (let i = 0; i < remoteSubscriptions.length; i++) {
const remote = remoteSubscriptions[i]; const remote = remoteSubscriptions[i];
const local = await this.add(remote.base_url, remote.topic, false); const local = await this.add(remote.base_url, remote.topic, false);
const reservation = const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
remoteReservations?.find(
(r) => remote.base_url === config.base_url && remote.topic === r.topic
) || null;
await this.update(local.id, { await this.update(local.id, {
displayName: remote.display_name, // May be undefined displayName: remote.display_name, // May be undefined
reservation: reservation, // May be null! reservation: reservation, // May be null!
@ -122,9 +114,7 @@ class SubscriptionManager {
/** Adds/replaces notifications, will not throw if they exist */ /** Adds/replaces notifications, will not throw if they exist */
async addNotifications(subscriptionId, notifications) { async addNotifications(subscriptionId, notifications) {
const notificationsWithSubscriptionId = notifications.map( const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId }));
(notification) => ({ ...notification, subscriptionId })
);
const lastNotificationId = notifications.at(-1).id; const lastNotificationId = notifications.at(-1).id;
await db.notifications.bulkPut(notificationsWithSubscriptionId); await db.notifications.bulkPut(notificationsWithSubscriptionId);
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {
@ -158,9 +148,7 @@ class SubscriptionManager {
} }
async markNotificationsRead(subscriptionId) { async markNotificationsRead(subscriptionId) {
await db.notifications await db.notifications.where({ subscriptionId: subscriptionId, new: 1 }).modify({ new: 0 });
.where({ subscriptionId: subscriptionId, new: 1 })
.modify({ new: 0 });
} }
async setMutedUntil(subscriptionId, mutedUntil) { async setMutedUntil(subscriptionId, mutedUntil) {

View File

@ -15,12 +15,7 @@ export const throwAppError = async (response) => {
} }
const error = await maybeToJson(response); const error = await maybeToJson(response);
if (error?.code) { if (error?.code) {
console.log( console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response);
`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${
error.error || ""
}`,
response
);
if (error.code === UserExistsError.CODE) { if (error.code === UserExistsError.CODE) {
throw new UserExistsError(); throw new UserExistsError();
} else if (error.code === TopicReservedError.CODE) { } else if (error.code === TopicReservedError.CODE) {

View File

@ -10,37 +10,23 @@ import config from "./config";
import { Base64 } from "js-base64"; import { Base64 } from "js-base64";
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`; export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
export const topicUrlWs = (baseUrl, topic) => export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://");
`${topicUrl(baseUrl, topic)}/ws` export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;
.replaceAll("https://", "wss://") export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
.replaceAll("http://", "ws://"); export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
export const topicUrlJson = (baseUrl, topic) => export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
`${topicUrl(baseUrl, topic)}/json`; export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
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 accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`; export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`; export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`; export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
export const accountSubscriptionUrl = (baseUrl) => export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`;
`${baseUrl}/v1/account/subscription`; export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reservation`;
export const accountReservationUrl = (baseUrl) => export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`;
`${baseUrl}/v1/account/reservation`; export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;
export const accountReservationSingleUrl = (baseUrl, topic) => export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
`${baseUrl}/v1/account/reservation/${topic}`;
export const accountBillingSubscriptionUrl = (baseUrl) =>
`${baseUrl}/v1/account/billing/subscription`;
export const accountBillingPortalUrl = (baseUrl) =>
`${baseUrl}/v1/account/billing/portal`;
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
export const accountPhoneVerifyUrl = (baseUrl) => export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
`${baseUrl}/v1/account/phone/verify`;
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
@ -208,9 +194,7 @@ export const formatShortDateTime = (timestamp) => {
}; };
export const formatShortDate = (timestamp) => { export const formatShortDate = (timestamp) => {
return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format( return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000));
new Date(timestamp * 1000)
);
}; };
export const formatBytes = (bytes, decimals = 2) => { export const formatBytes = (bytes, decimals = 2) => {
@ -312,8 +296,7 @@ export async function* fetchLinesIterator(fileURL, headers) {
} }
export const randomAlphanumericString = (len) => { export const randomAlphanumericString = (len) => {
const alphabet = const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let id = ""; let id = "";
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
id += alphabet[(Math.random() * alphabet.length) | 0]; id += alphabet[(Math.random() * alphabet.length) | 0];

View File

@ -38,18 +38,8 @@ import DialogContent from "@mui/material/DialogContent";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import routes from "./routes"; import routes from "./routes";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
formatBytes, import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
formatShortDate,
formatShortDateTime,
openUrl,
} from "../app/utils";
import accountApi, {
LimitBasis,
Role,
SubscriptionInterval,
SubscriptionStatus,
} from "../app/AccountApi";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { Pref, PrefGroup } from "./Pref"; import { Pref, PrefGroup } from "./Pref";
import db from "../app/db"; import db from "../app/db";
@ -108,11 +98,7 @@ const Username = () => {
const labelId = "prefUsername"; const labelId = "prefUsername";
return ( return (
<Pref <Pref labelId={labelId} title={t("account_basics_username_title")} description={t("account_basics_username_description")}>
labelId={labelId}
title={t("account_basics_username_title")}
description={t("account_basics_username_description")}
>
<div aria-labelledby={labelId}> <div aria-labelledby={labelId}>
{session.username()} {session.username()}
{account?.role === Role.ADMIN ? ( {account?.role === Role.ADMIN ? (
@ -146,30 +132,16 @@ const ChangePassword = () => {
}; };
return ( return (
<Pref <Pref labelId={labelId} title={t("account_basics_password_title")} description={t("account_basics_password_description")}>
labelId={labelId}
title={t("account_basics_password_title")}
description={t("account_basics_password_description")}
>
<div aria-labelledby={labelId}> <div aria-labelledby={labelId}>
<Typography <Typography color="gray" sx={{ float: "left", fontSize: "0.7rem", lineHeight: "3.5" }}>
color="gray"
sx={{ float: "left", fontSize: "0.7rem", lineHeight: "3.5" }}
>
</Typography> </Typography>
<IconButton <IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
onClick={handleDialogOpen}
aria-label={t("account_basics_password_description")}
>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
</div> </div>
<ChangePasswordDialog <ChangePasswordDialog key={`changePasswordDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
key={`changePasswordDialog${dialogKey}`}
open={dialogOpen}
onClose={handleDialogClose}
/>
</Pref> </Pref>
); );
}; };
@ -190,9 +162,7 @@ const ChangePasswordDialog = (props) => {
} catch (e) { } catch (e) {
console.log(`[Account] Error changing password`, e); console.log(`[Account] Error changing password`, e);
if (e instanceof IncorrectPasswordError) { if (e instanceof IncorrectPasswordError) {
setError( setError(t("account_basics_password_dialog_current_password_incorrect"));
t("account_basics_password_dialog_current_password_incorrect")
);
} else if (e instanceof UnauthorizedError) { } else if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else { } else {
@ -209,9 +179,7 @@ const ChangePasswordDialog = (props) => {
margin="dense" margin="dense"
id="current-password" id="current-password"
label={t("account_basics_password_dialog_current_password_label")} label={t("account_basics_password_dialog_current_password_label")}
aria-label={t( aria-label={t("account_basics_password_dialog_current_password_label")}
"account_basics_password_dialog_current_password_label"
)}
type="password" type="password"
value={currentPassword} value={currentPassword}
onChange={(ev) => setCurrentPassword(ev.target.value)} onChange={(ev) => setCurrentPassword(ev.target.value)}
@ -233,9 +201,7 @@ const ChangePasswordDialog = (props) => {
margin="dense" margin="dense"
id="confirm" id="confirm"
label={t("account_basics_password_dialog_confirm_password_label")} label={t("account_basics_password_dialog_confirm_password_label")}
aria-label={t( aria-label={t("account_basics_password_dialog_confirm_password_label")}
"account_basics_password_dialog_confirm_password_label"
)}
type="password" type="password"
value={confirmPassword} value={confirmPassword}
onChange={(ev) => setConfirmPassword(ev.target.value)} onChange={(ev) => setConfirmPassword(ev.target.value)}
@ -245,14 +211,7 @@ const ChangePasswordDialog = (props) => {
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button> <Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button <Button onClick={handleDialogSubmit} disabled={newPassword.length === 0 || currentPassword.length === 0 || newPassword !== confirmPassword}>
onClick={handleDialogSubmit}
disabled={
newPassword.length === 0 ||
currentPassword.length === 0 ||
newPassword !== confirmPassword
}
>
{t("account_basics_password_dialog_button_submit")} {t("account_basics_password_dialog_button_submit")}
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -299,9 +258,7 @@ const AccountType = () => {
: t("account_basics_tier_admin_suffix_no_tier"); : t("account_basics_tier_admin_suffix_no_tier");
accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`; accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`;
} else if (!account.tier) { } else if (!account.tier) {
accountType = config.enable_payments accountType = config.enable_payments ? t("account_basics_tier_free") : t("account_basics_tier_basic");
? t("account_basics_tier_free")
: t("account_basics_tier_basic");
} else { } else {
accountType = account.tier.name; accountType = account.tier.name;
if (account.billing?.interval === SubscriptionInterval.MONTH) { if (account.billing?.interval === SubscriptionInterval.MONTH) {
@ -313,10 +270,7 @@ const AccountType = () => {
return ( return (
<Pref <Pref
alignTop={ alignTop={account.billing?.status === SubscriptionStatus.PAST_DUE || account.billing?.cancel_at > 0}
account.billing?.status === SubscriptionStatus.PAST_DUE ||
account.billing?.cancel_at > 0
}
title={t("account_basics_tier_title")} title={t("account_basics_tier_title")}
description={t("account_basics_tier_description")} description={t("account_basics_tier_description")}
> >
@ -333,49 +287,23 @@ const AccountType = () => {
</span> </span>
</Tooltip> </Tooltip>
)} )}
{config.enable_payments && {config.enable_payments && account.role === Role.USER && !account.billing?.subscription && (
account.role === Role.USER && <Button variant="outlined" size="small" startIcon={<CelebrationIcon sx={{ color: "#55b86e" }} />} onClick={handleUpgradeClick} sx={{ ml: 1 }}>
!account.billing?.subscription && ( {t("account_basics_tier_upgrade_button")}
<Button </Button>
variant="outlined" )}
size="small" {config.enable_payments && account.role === Role.USER && account.billing?.subscription && (
startIcon={<CelebrationIcon sx={{ color: "#55b86e" }} />} <Button variant="outlined" size="small" onClick={handleUpgradeClick} sx={{ ml: 1 }}>
onClick={handleUpgradeClick} {t("account_basics_tier_change_button")}
sx={{ ml: 1 }} </Button>
> )}
{t("account_basics_tier_upgrade_button")} {config.enable_payments && account.role === Role.USER && account.billing?.customer && (
</Button> <Button variant="outlined" size="small" onClick={handleManageBilling} sx={{ ml: 1 }}>
)} {t("account_basics_tier_manage_billing_button")}
{config.enable_payments && </Button>
account.role === Role.USER && )}
account.billing?.subscription && (
<Button
variant="outlined"
size="small"
onClick={handleUpgradeClick}
sx={{ ml: 1 }}
>
{t("account_basics_tier_change_button")}
</Button>
)}
{config.enable_payments &&
account.role === Role.USER &&
account.billing?.customer && (
<Button
variant="outlined"
size="small"
onClick={handleManageBilling}
sx={{ ml: 1 }}
>
{t("account_basics_tier_manage_billing_button")}
</Button>
)}
{config.enable_payments && ( {config.enable_payments && (
<UpgradeDialog <UpgradeDialog key={`upgradeDialogFromAccount${upgradeDialogKey}`} open={upgradeDialogOpen} onCancel={() => setUpgradeDialogOpen(false)} />
key={`upgradeDialogFromAccount${upgradeDialogKey}`}
open={upgradeDialogOpen}
onCancel={() => setUpgradeDialogOpen(false)}
/>
)} )}
</div> </div>
{account.billing?.status === SubscriptionStatus.PAST_DUE && ( {account.billing?.status === SubscriptionStatus.PAST_DUE && (
@ -456,11 +384,7 @@ const PhoneNumbers = () => {
} }
return ( return (
<Pref <Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
labelId={labelId}
title={t("account_basics_phone_numbers_title")}
description={t("account_basics_phone_numbers_description")}
>
<div aria-labelledby={labelId}> <div aria-labelledby={labelId}>
{account?.phone_numbers?.map((phoneNumber) => ( {account?.phone_numbers?.map((phoneNumber) => (
<Chip <Chip
@ -474,18 +398,12 @@ const PhoneNumbers = () => {
onDelete={() => handleDelete(phoneNumber)} onDelete={() => handleDelete(phoneNumber)}
/> />
))} ))}
{!account?.phone_numbers && ( {!account?.phone_numbers && <em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>}
<em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>
)}
<IconButton onClick={handleDialogOpen}> <IconButton onClick={handleDialogOpen}>
<AddIcon /> <AddIcon />
</IconButton> </IconButton>
</div> </div>
<AddPhoneNumberDialog <AddPhoneNumberDialog key={`addPhoneNumberDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
key={`addPhoneNumberDialog${dialogKey}`}
open={dialogOpen}
onClose={handleDialogClose}
/>
<Portal> <Portal>
<Snackbar <Snackbar
open={snackOpen} open={snackOpen}
@ -561,22 +479,16 @@ const AddPhoneNumberDialog = (props) => {
return ( return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}> <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle> <DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
{t("account_basics_phone_numbers_dialog_title")}
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>{t("account_basics_phone_numbers_dialog_description")}</DialogContentText>
{t("account_basics_phone_numbers_dialog_description")}
</DialogContentText>
{!verificationCodeSent && ( {!verificationCodeSent && (
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
<TextField <TextField
margin="dense" margin="dense"
label={t("account_basics_phone_numbers_dialog_number_label")} label={t("account_basics_phone_numbers_dialog_number_label")}
aria-label={t("account_basics_phone_numbers_dialog_number_label")} aria-label={t("account_basics_phone_numbers_dialog_number_label")}
placeholder={t( placeholder={t("account_basics_phone_numbers_dialog_number_placeholder")}
"account_basics_phone_numbers_dialog_number_placeholder"
)}
type="tel" type="tel"
value={phoneNumber} value={phoneNumber}
onChange={(ev) => setPhoneNumber(ev.target.value)} onChange={(ev) => setPhoneNumber(ev.target.value)}
@ -585,28 +497,15 @@ const AddPhoneNumberDialog = (props) => {
sx={{ flexGrow: 1 }} sx={{ flexGrow: 1 }}
/> />
<FormControl sx={{ flexWrap: "nowrap" }}> <FormControl sx={{ flexWrap: "nowrap" }}>
<RadioGroup <RadioGroup row sx={{ flexGrow: 1, marginTop: "8px", marginLeft: "5px" }}>
row
sx={{ flexGrow: 1, marginTop: "8px", marginLeft: "5px" }}
>
<FormControlLabel <FormControlLabel
value="sms" value="sms"
control={ control={<Radio checked={channel === "sms"} onChange={(e) => setChannel(e.target.value)} />}
<Radio
checked={channel === "sms"}
onChange={(e) => setChannel(e.target.value)}
/>
}
label={t("account_basics_phone_numbers_dialog_channel_sms")} label={t("account_basics_phone_numbers_dialog_channel_sms")}
/> />
<FormControlLabel <FormControlLabel
value="call" value="call"
control={ control={<Radio checked={channel === "call"} onChange={(e) => setChannel(e.target.value)} />}
<Radio
checked={channel === "call"}
onChange={(e) => setChannel(e.target.value)}
/>
}
label={t("account_basics_phone_numbers_dialog_channel_call")} label={t("account_basics_phone_numbers_dialog_channel_call")}
sx={{ marginRight: 0 }} sx={{ marginRight: 0 }}
/> />
@ -619,9 +518,7 @@ const AddPhoneNumberDialog = (props) => {
margin="dense" margin="dense"
label={t("account_basics_phone_numbers_dialog_code_label")} label={t("account_basics_phone_numbers_dialog_code_label")}
aria-label={t("account_basics_phone_numbers_dialog_code_label")} aria-label={t("account_basics_phone_numbers_dialog_code_label")}
placeholder={t( placeholder={t("account_basics_phone_numbers_dialog_code_placeholder")}
"account_basics_phone_numbers_dialog_code_placeholder"
)}
type="text" type="text"
value={code} value={code}
onChange={(ev) => setCode(ev.target.value)} onChange={(ev) => setCode(ev.target.value)}
@ -632,21 +529,11 @@ const AddPhoneNumberDialog = (props) => {
)} )}
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={handleCancel}> <Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
{verificationCodeSent ? t("common_back") : t("common_cancel")} <Button onClick={handleDialogSubmit} disabled={sending || !/^\+\d+$/.test(phoneNumber)}>
</Button> {!verificationCodeSent && channel === "sms" && t("account_basics_phone_numbers_dialog_verify_button_sms")}
<Button {!verificationCodeSent && channel === "call" && t("account_basics_phone_numbers_dialog_verify_button_call")}
onClick={handleDialogSubmit} {verificationCodeSent && t("account_basics_phone_numbers_dialog_check_verification_button")}
disabled={sending || !/^\+\d+$/.test(phoneNumber)}
>
{!verificationCodeSent &&
channel === "sms" &&
t("account_basics_phone_numbers_dialog_verify_button_sms")}
{!verificationCodeSent &&
channel === "call" &&
t("account_basics_phone_numbers_dialog_verify_button_call")}
{verificationCodeSent &&
t("account_basics_phone_numbers_dialog_check_verification_button")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</Dialog> </Dialog>
@ -687,14 +574,7 @@ const Stats = () => {
</div> </div>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={ value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
account.role === Role.USER && account.limits.reservations > 0
? normalize(
account.stats.reservations,
account.limits.reservations
)
: 100
}
/> />
</Pref> </Pref>
)} )}
@ -722,14 +602,7 @@ const Stats = () => {
: t("account_usage_unlimited")} : t("account_usage_unlimited")}
</Typography> </Typography>
</div> </div>
<LinearProgress <LinearProgress variant="determinate" value={account.role === Role.USER ? normalize(account.stats.messages, account.limits.messages) : 100} />
variant="determinate"
value={
account.role === Role.USER
? normalize(account.stats.messages, account.limits.messages)
: 100
}
/>
</Pref> </Pref>
{config.enable_emails && ( {config.enable_emails && (
<Pref <Pref
@ -756,64 +629,49 @@ const Stats = () => {
: t("account_usage_unlimited")} : t("account_usage_unlimited")}
</Typography> </Typography>
</div> </div>
<LinearProgress variant="determinate" value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100} />
</Pref>
)}
{config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) && (
<Pref
title={
<>
{t("account_usage_calls_title")}
<Tooltip title={t("account_usage_limits_reset_daily")}>
<span>
<InfoIcon />
</span>
</Tooltip>
</>
}
>
<div>
<Typography variant="body2" sx={{ float: "left" }}>
{account.stats.calls.toLocaleString()}
</Typography>
<Typography variant="body2" sx={{ float: "right" }}>
{account.role === Role.USER
? t("account_usage_of_limit", {
limit: account.limits.calls.toLocaleString(),
})
: t("account_usage_unlimited")}
</Typography>
</div>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={ value={account.role === Role.USER && account.limits.calls > 0 ? normalize(account.stats.calls, account.limits.calls) : 100}
account.role === Role.USER
? normalize(account.stats.emails, account.limits.emails)
: 100
}
/> />
</Pref> </Pref>
)} )}
{config.enable_calls &&
(account.role === Role.ADMIN || account.limits.calls > 0) && (
<Pref
title={
<>
{t("account_usage_calls_title")}
<Tooltip title={t("account_usage_limits_reset_daily")}>
<span>
<InfoIcon />
</span>
</Tooltip>
</>
}
>
<div>
<Typography variant="body2" sx={{ float: "left" }}>
{account.stats.calls.toLocaleString()}
</Typography>
<Typography variant="body2" sx={{ float: "right" }}>
{account.role === Role.USER
? t("account_usage_of_limit", {
limit: account.limits.calls.toLocaleString(),
})
: t("account_usage_unlimited")}
</Typography>
</div>
<LinearProgress
variant="determinate"
value={
account.role === Role.USER && account.limits.calls > 0
? normalize(account.stats.calls, account.limits.calls)
: 100
}
/>
</Pref>
)}
<Pref <Pref
alignTop alignTop
title={t("account_usage_attachment_storage_title")} title={t("account_usage_attachment_storage_title")}
description={t("account_usage_attachment_storage_description", { description={t("account_usage_attachment_storage_description", {
filesize: formatBytes(account.limits.attachment_file_size), filesize: formatBytes(account.limits.attachment_file_size),
expiry: humanizeDuration( expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, {
account.limits.attachment_expiry_duration * 1000, language: i18n.resolvedLanguage,
{ fallbacks: ["en"],
language: i18n.resolvedLanguage, }),
fallbacks: ["en"],
}
),
})} })}
> >
<div> <div>
@ -830,49 +688,36 @@ const Stats = () => {
</div> </div>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={ value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
account.role === Role.USER
? normalize(
account.stats.attachment_total_size,
account.limits.attachment_total_size
)
: 100
}
/> />
</Pref> </Pref>
{config.enable_reservations && {config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && (
account.role === Role.USER && <Pref
account.limits.reservations === 0 && ( title={
<Pref <>
title={ {t("account_usage_reservations_title")}
<> {config.enable_payments && <ProChip />}
{t("account_usage_reservations_title")} </>
{config.enable_payments && <ProChip />} }
</> >
} <em>{t("account_usage_reservations_none")}</em>
> </Pref>
<em>{t("account_usage_reservations_none")}</em> )}
</Pref> {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && (
)} <Pref
{config.enable_calls && title={
account.role === Role.USER && <>
account.limits.calls === 0 && ( {t("account_usage_calls_title")}
<Pref {config.enable_payments && <ProChip />}
title={ </>
<> }
{t("account_usage_calls_title")} >
{config.enable_payments && <ProChip />} <em>{t("account_usage_calls_none")}</em>
</> </Pref>
} )}
>
<em>{t("account_usage_calls_none")}</em>
</Pref>
)}
</PrefGroup> </PrefGroup>
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && ( {account.role === Role.USER && account.limits.basis === LimitBasis.IP && (
<Typography variant="body1"> <Typography variant="body1">{t("account_usage_basis_ip_description")}</Typography>
{t("account_usage_basis_ip_description")}
</Typography>
)} )}
</Card> </Card>
); );
@ -928,15 +773,9 @@ const Tokens = () => {
{tokens?.length > 0 && <TokensTable tokens={tokens} />} {tokens?.length > 0 && <TokensTable tokens={tokens} />}
</CardContent> </CardContent>
<CardActions> <CardActions>
<Button onClick={handleCreateClick}> <Button onClick={handleCreateClick}>{t("account_tokens_table_create_token_button")}</Button>
{t("account_tokens_table_create_token_button")}
</Button>
</CardActions> </CardActions>
<TokenDialog <TokenDialog key={`tokenDialogCreate${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
key={`tokenDialogCreate${dialogKey}`}
open={dialogOpen}
onClose={handleDialogClose}
/>
</Card> </Card>
); );
}; };
@ -984,9 +823,7 @@ const TokensTable = (props) => {
<Table size="small" aria-label={t("account_tokens_title")}> <Table size="small" aria-label={t("account_tokens_title")}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell sx={{ paddingLeft: 0 }}> <TableCell sx={{ paddingLeft: 0 }}>{t("account_tokens_table_token_header")}</TableCell>
{t("account_tokens_table_token_header")}
</TableCell>
<TableCell>{t("account_tokens_table_label_header")}</TableCell> <TableCell>{t("account_tokens_table_label_header")}</TableCell>
<TableCell>{t("account_tokens_table_expires_header")}</TableCell> <TableCell>{t("account_tokens_table_expires_header")}</TableCell>
<TableCell>{t("account_tokens_table_last_access_header")}</TableCell> <TableCell>{t("account_tokens_table_last_access_header")}</TableCell>
@ -995,25 +832,12 @@ const TokensTable = (props) => {
</TableHead> </TableHead>
<TableBody> <TableBody>
{tokens.map((token) => ( {tokens.map((token) => (
<TableRow <TableRow key={token.token} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
key={token.token} <TableCell component="th" scope="row" sx={{ paddingLeft: 0, whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_token_header")}>
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell
component="th"
scope="row"
sx={{ paddingLeft: 0, whiteSpace: "nowrap" }}
aria-label={t("account_tokens_table_token_header")}
>
<span> <span>
<span style={{ fontFamily: "Monospace", fontSize: "0.9rem" }}> <span style={{ fontFamily: "Monospace", fontSize: "0.9rem" }}>{token.token.slice(0, 12)}</span>
{token.token.slice(0, 12)}
</span>
... ...
<Tooltip <Tooltip title={t("common_copy_to_clipboard")} placement="right">
title={t("common_copy_to_clipboard")}
placement="right"
>
<IconButton onClick={() => handleCopy(token.token)}> <IconButton onClick={() => handleCopy(token.token)}>
<ContentCopy /> <ContentCopy />
</IconButton> </IconButton>
@ -1021,25 +845,13 @@ const TokensTable = (props) => {
</span> </span>
</TableCell> </TableCell>
<TableCell aria-label={t("account_tokens_table_label_header")}> <TableCell aria-label={t("account_tokens_table_label_header")}>
{token.token === session.token() && ( {token.token === session.token() && <em>{t("account_tokens_table_current_session")}</em>}
<em>{t("account_tokens_table_current_session")}</em>
)}
{token.token !== session.token() && (token.label || "-")} {token.token !== session.token() && (token.label || "-")}
</TableCell> </TableCell>
<TableCell <TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
sx={{ whiteSpace: "nowrap" }} {token.expires ? formatShortDateTime(token.expires) : <em>{t("account_tokens_table_never_expires")}</em>}
aria-label={t("account_tokens_table_expires_header")}
>
{token.expires ? (
formatShortDateTime(token.expires)
) : (
<em>{t("account_tokens_table_never_expires")}</em>
)}
</TableCell> </TableCell>
<TableCell <TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
sx={{ whiteSpace: "nowrap" }}
aria-label={t("account_tokens_table_last_access_header")}
>
<div style={{ display: "flex", alignItems: "center" }}> <div style={{ display: "flex", alignItems: "center" }}>
<span>{formatShortDateTime(token.last_access)}</span> <span>{formatShortDateTime(token.last_access)}</span>
<Tooltip <Tooltip
@ -1047,13 +859,7 @@ const TokensTable = (props) => {
ip: token.last_origin, ip: token.last_origin,
})} })}
> >
<IconButton <IconButton onClick={() => openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}>
onClick={() =>
openUrl(
`https://whatismyipaddress.com/ip/${token.last_origin}`
)
}
>
<Public /> <Public />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@ -1062,24 +868,16 @@ const TokensTable = (props) => {
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}> <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{token.token !== session.token() && ( {token.token !== session.token() && (
<> <>
<IconButton <IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
onClick={() => handleEditClick(token)}
aria-label={t("account_tokens_dialog_title_edit")}
>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
<IconButton <IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
onClick={() => handleDeleteClick(token)}
aria-label={t("account_tokens_dialog_title_delete")}
>
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
</> </>
)} )}
{token.token === session.token() && ( {token.token === session.token() && (
<Tooltip <Tooltip title={t("account_tokens_table_cannot_delete_or_edit")}>
title={t("account_tokens_table_cannot_delete_or_edit")}
>
<span> <span>
<IconButton disabled> <IconButton disabled>
<EditIcon /> <EditIcon />
@ -1095,24 +893,10 @@ const TokensTable = (props) => {
))} ))}
</TableBody> </TableBody>
<Portal> <Portal>
<Snackbar <Snackbar open={snackOpen} autoHideDuration={3000} onClose={() => setSnackOpen(false)} message={t("account_tokens_table_copied_to_clipboard")} />
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("account_tokens_table_copied_to_clipboard")}
/>
</Portal> </Portal>
<TokenDialog <TokenDialog key={`tokenDialogEdit${upsertDialogKey}`} open={upsertDialogOpen} token={selectedToken} onClose={handleDialogClose} />
key={`tokenDialogEdit${upsertDialogKey}`} <TokenDeleteDialog open={deleteDialogOpen} token={selectedToken} onClose={handleDialogClose} />
open={upsertDialogOpen}
token={selectedToken}
onClose={handleDialogClose}
/>
<TokenDeleteDialog
open={deleteDialogOpen}
token={selectedToken}
onClose={handleDialogClose}
/>
</Table> </Table>
); );
}; };
@ -1144,18 +928,8 @@ const TokenDialog = (props) => {
}; };
return ( return (
<Dialog <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
open={props.open} <DialogTitle>{editMode ? t("account_tokens_dialog_title_edit") : t("account_tokens_dialog_title_create")}</DialogTitle>
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>
{editMode
? t("account_tokens_dialog_title_edit")
: t("account_tokens_dialog_title_create")}
</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
margin="dense" margin="dense"
@ -1169,52 +943,22 @@ const TokenDialog = (props) => {
variant="standard" variant="standard"
/> />
<FormControl fullWidth variant="standard" sx={{ mt: 1 }}> <FormControl fullWidth variant="standard" sx={{ mt: 1 }}>
<Select <Select value={expires} onChange={(ev) => setExpires(ev.target.value)} aria-label={t("account_tokens_dialog_expires_label")}>
value={expires} {editMode && <MenuItem value={-1}>{t("account_tokens_dialog_expires_unchanged")}</MenuItem>}
onChange={(ev) => setExpires(ev.target.value)} <MenuItem value={0}>{t("account_tokens_dialog_expires_never")}</MenuItem>
aria-label={t("account_tokens_dialog_expires_label")} <MenuItem value={21600}>{t("account_tokens_dialog_expires_x_hours", { hours: 6 })}</MenuItem>
> <MenuItem value={43200}>{t("account_tokens_dialog_expires_x_hours", { hours: 12 })}</MenuItem>
{editMode && ( <MenuItem value={259200}>{t("account_tokens_dialog_expires_x_days", { days: 3 })}</MenuItem>
<MenuItem value={-1}> <MenuItem value={604800}>{t("account_tokens_dialog_expires_x_days", { days: 7 })}</MenuItem>
{t("account_tokens_dialog_expires_unchanged")} <MenuItem value={2592000}>{t("account_tokens_dialog_expires_x_days", { days: 30 })}</MenuItem>
</MenuItem> <MenuItem value={7776000}>{t("account_tokens_dialog_expires_x_days", { days: 90 })}</MenuItem>
)} <MenuItem value={15552000}>{t("account_tokens_dialog_expires_x_days", { days: 180 })}</MenuItem>
<MenuItem value={0}>
{t("account_tokens_dialog_expires_never")}
</MenuItem>
<MenuItem value={21600}>
{t("account_tokens_dialog_expires_x_hours", { hours: 6 })}
</MenuItem>
<MenuItem value={43200}>
{t("account_tokens_dialog_expires_x_hours", { hours: 12 })}
</MenuItem>
<MenuItem value={259200}>
{t("account_tokens_dialog_expires_x_days", { days: 3 })}
</MenuItem>
<MenuItem value={604800}>
{t("account_tokens_dialog_expires_x_days", { days: 7 })}
</MenuItem>
<MenuItem value={2592000}>
{t("account_tokens_dialog_expires_x_days", { days: 30 })}
</MenuItem>
<MenuItem value={7776000}>
{t("account_tokens_dialog_expires_x_days", { days: 90 })}
</MenuItem>
<MenuItem value={15552000}>
{t("account_tokens_dialog_expires_x_days", { days: 180 })}
</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onClose}> <Button onClick={props.onClose}>{t("account_tokens_dialog_button_cancel")}</Button>
{t("account_tokens_dialog_button_cancel")} <Button onClick={handleSubmit}>{editMode ? t("account_tokens_dialog_button_update") : t("account_tokens_dialog_button_create")}</Button>
</Button>
<Button onClick={handleSubmit}>
{editMode
? t("account_tokens_dialog_button_update")
: t("account_tokens_dialog_button_create")}
</Button>
</DialogFooter> </DialogFooter>
</Dialog> </Dialog>
); );
@ -1285,26 +1029,13 @@ const DeleteAccount = () => {
}; };
return ( return (
<Pref <Pref title={t("account_delete_title")} description={t("account_delete_description")}>
title={t("account_delete_title")}
description={t("account_delete_description")}
>
<div> <div>
<Button <Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>
fullWidth={false}
variant="outlined"
color="error"
startIcon={<DeleteOutlineIcon />}
onClick={handleDialogOpen}
>
{t("account_delete_title")} {t("account_delete_title")}
</Button> </Button>
</div> </div>
<DeleteAccountDialog <DeleteAccountDialog key={`deleteAccountDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
key={`deleteAccountDialog${dialogKey}`}
open={dialogOpen}
onClose={handleDialogClose}
/>
</Pref> </Pref>
); );
}; };
@ -1325,9 +1056,7 @@ const DeleteAccountDialog = (props) => {
} catch (e) { } catch (e) {
console.log(`[Account] Error deleting account`, e); console.log(`[Account] Error deleting account`, e);
if (e instanceof IncorrectPasswordError) { if (e instanceof IncorrectPasswordError) {
setError( setError(t("account_basics_password_dialog_current_password_incorrect"));
t("account_basics_password_dialog_current_password_incorrect")
);
} else if (e instanceof UnauthorizedError) { } else if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else { } else {
@ -1340,9 +1069,7 @@ const DeleteAccountDialog = (props) => {
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}> <Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("account_delete_title")}</DialogTitle> <DialogTitle>{t("account_delete_title")}</DialogTitle>
<DialogContent> <DialogContent>
<Typography variant="body1"> <Typography variant="body1">{t("account_delete_dialog_description")}</Typography>
{t("account_delete_dialog_description")}
</Typography>
<TextField <TextField
margin="dense" margin="dense"
id="account-delete-confirm" id="account-delete-confirm"
@ -1361,14 +1088,8 @@ const DeleteAccountDialog = (props) => {
)} )}
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onClose}> <Button onClick={props.onClose}>{t("account_delete_dialog_button_cancel")}</Button>
{t("account_delete_dialog_button_cancel")} <Button onClick={handleSubmit} color="error" disabled={password.length === 0}>
</Button>
<Button
onClick={handleSubmit}
color="error"
disabled={password.length === 0}
>
{t("account_delete_dialog_button_submit")} {t("account_delete_dialog_button_submit")}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -51,8 +51,7 @@ const ActionBar = (props) => {
<Toolbar <Toolbar
sx={{ sx={{
pr: "24px", pr: "24px",
background: background: "linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%)",
"linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%)",
}} }}
> >
<IconButton <IconButton
@ -77,12 +76,7 @@ const ActionBar = (props) => {
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}> <Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{title} {title}
</Typography> </Typography>
{props.selected && ( {props.selected && <SettingsIcons subscription={props.selected} onUnsubscribe={props.onUnsubscribe} />}
<SettingsIcons
subscription={props.selected}
onUnsubscribe={props.onUnsubscribe}
/>
)}
<ProfileIcon /> <ProfileIcon />
</Toolbar> </Toolbar>
</AppBar> </AppBar>
@ -101,34 +95,13 @@ const SettingsIcons = (props) => {
return ( return (
<> <>
<IconButton <IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} aria-label={t("action_bar_toggle_mute")}>
color="inherit" {subscription.mutedUntil ? <NotificationsOffIcon /> : <NotificationsIcon />}
size="large"
edge="end"
onClick={handleToggleMute}
aria-label={t("action_bar_toggle_mute")}
>
{subscription.mutedUntil ? (
<NotificationsOffIcon />
) : (
<NotificationsIcon />
)}
</IconButton> </IconButton>
<IconButton <IconButton color="inherit" size="large" edge="end" onClick={(ev) => setAnchorEl(ev.currentTarget)} aria-label={t("action_bar_toggle_action_menu")}>
color="inherit"
size="large"
edge="end"
onClick={(ev) => setAnchorEl(ev.currentTarget)}
aria-label={t("action_bar_toggle_action_menu")}
>
<MoreVertIcon /> <MoreVertIcon />
</IconButton> </IconButton>
<SubscriptionPopup <SubscriptionPopup subscription={subscription} anchor={anchorEl} placement="right" onClose={() => setAnchorEl(null)} />
subscription={subscription}
anchor={anchorEl}
placement="right"
onClose={() => setAnchorEl(null)}
/>
</> </>
); );
}; };
@ -159,43 +132,21 @@ const ProfileIcon = () => {
return ( return (
<> <>
{session.exists() && ( {session.exists() && (
<IconButton <IconButton color="inherit" size="large" edge="end" onClick={handleClick} aria-label={t("action_bar_profile_title")}>
color="inherit"
size="large"
edge="end"
onClick={handleClick}
aria-label={t("action_bar_profile_title")}
>
<AccountCircleIcon /> <AccountCircleIcon />
</IconButton> </IconButton>
)} )}
{!session.exists() && config.enable_login && ( {!session.exists() && config.enable_login && (
<Button <Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{ m: 1 }} aria-label={t("action_bar_sign_in")}>
color="inherit"
variant="text"
onClick={() => navigate(routes.login)}
sx={{ m: 1 }}
aria-label={t("action_bar_sign_in")}
>
{t("action_bar_sign_in")} {t("action_bar_sign_in")}
</Button> </Button>
)} )}
{!session.exists() && config.enable_signup && ( {!session.exists() && config.enable_signup && (
<Button <Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}>
color="inherit"
variant="outlined"
onClick={() => navigate(routes.signup)}
aria-label={t("action_bar_sign_up")}
>
{t("action_bar_sign_up")} {t("action_bar_sign_up")}
</Button> </Button>
)} )}
<PopupMenu <PopupMenu horizontal="right" anchorEl={anchorEl} open={open} onClose={handleClose}>
horizontal="right"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
>
<MenuItem onClick={() => navigate(routes.account)}> <MenuItem onClick={() => navigate(routes.account)}>
<ListItemIcon> <ListItemIcon>
<Person /> <Person />

View File

@ -1,11 +1,5 @@
import * as React from "react"; import * as React from "react";
import { import { createContext, Suspense, useContext, useEffect, useState } from "react";
createContext,
Suspense,
useContext,
useEffect,
useState,
} from "react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { ThemeProvider } from "@mui/material/styles"; import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline"; import CssBaseline from "@mui/material/CssBaseline";
@ -19,21 +13,11 @@ import Preferences from "./Preferences";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import { import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
BrowserRouter,
Outlet,
Route,
Routes,
useParams,
} from "react-router-dom";
import { expandUrl } from "../app/utils"; import { expandUrl } from "../app/utils";
import ErrorBoundary from "./ErrorBoundary"; import ErrorBoundary from "./ErrorBoundary";
import routes from "./routes"; import routes from "./routes";
import { import { useAccountListener, useBackgroundProcesses, useConnectionListeners } from "./hooks";
useAccountListener,
useBackgroundProcesses,
useConnectionListeners,
} from "./hooks";
import PublishDialog from "./PublishDialog"; import PublishDialog from "./PublishDialog";
import Messaging from "./Messaging"; import Messaging from "./Messaging";
import "./i18n"; // Translations! import "./i18n"; // Translations!
@ -60,14 +44,8 @@ const App = () => {
<Route path={routes.app} element={<AllSubscriptions />} /> <Route path={routes.app} element={<AllSubscriptions />} />
<Route path={routes.account} element={<Account />} /> <Route path={routes.account} element={<Account />} />
<Route path={routes.settings} element={<Preferences />} /> <Route path={routes.settings} element={<Preferences />} />
<Route <Route path={routes.subscription} element={<SingleSubscription />} />
path={routes.subscription} <Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
element={<SingleSubscription />}
/>
<Route
path={routes.subscriptionExternal}
element={<SingleSubscription />}
/>
</Route> </Route>
</Routes> </Routes>
</ErrorBoundary> </ErrorBoundary>
@ -82,22 +60,15 @@ const Layout = () => {
const params = useParams(); const params = useParams();
const { account, setAccount } = useContext(AccountContext); const { account, setAccount } = useContext(AccountContext);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [notificationsGranted, setNotificationsGranted] = useState( const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
notifier.granted()
);
const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all()); const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all()); const subscriptions = useLiveQuery(() => subscriptionManager.all());
const subscriptionsWithoutInternal = subscriptions?.filter( const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
(s) => !s.internal const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
);
const newNotificationsCount =
subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
const [selected] = (subscriptionsWithoutInternal || []).filter((s) => { const [selected] = (subscriptionsWithoutInternal || []).filter((s) => {
return ( return (
(params.baseUrl && (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) ||
expandUrl(params.baseUrl).includes(s.baseUrl) &&
params.topic === s.topic) ||
(config.base_url === s.baseUrl && params.topic === s.topic) (config.base_url === s.baseUrl && params.topic === s.topic)
); );
}); });
@ -109,10 +80,7 @@ const Layout = () => {
return ( return (
<Box sx={{ display: "flex" }}> <Box sx={{ display: "flex" }}>
<ActionBar <ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
selected={selected}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
/>
<Navigation <Navigation
subscriptions={subscriptionsWithoutInternal} subscriptions={subscriptionsWithoutInternal}
selectedSubscription={selected} selectedSubscription={selected}
@ -120,9 +88,7 @@ const Layout = () => {
mobileDrawerOpen={mobileDrawerOpen} mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onNotificationGranted={setNotificationsGranted} onNotificationGranted={setNotificationsGranted}
onPublishMessageClick={() => onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)
}
/> />
<Main> <Main>
<Toolbar /> <Toolbar />
@ -133,11 +99,7 @@ const Layout = () => {
}} }}
/> />
</Main> </Main>
<Messaging <Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
selected={selected}
dialogOpenMode={sendDialogOpenMode}
onDialogOpenModeChange={setSendDialogOpenMode}
/>
</Box> </Box>
); );
}; };
@ -155,10 +117,7 @@ const Main = (props) => {
width: { sm: `calc(100% - ${Navigation.width}px)` }, width: { sm: `calc(100% - ${Navigation.width}px)` },
height: "100vh", height: "100vh",
overflow: "auto", overflow: "auto",
backgroundColor: (theme) => backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]),
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}} }}
> >
{props.children} {props.children}
@ -171,10 +130,7 @@ const Loader = () => (
open={true} open={true}
sx={{ sx={{
zIndex: 100000, zIndex: 100000,
backgroundColor: (theme) => backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]),
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}} }}
> >
<CircularProgress color="success" disableShrink /> <CircularProgress color="success" disableShrink />
@ -182,8 +138,7 @@ const Loader = () => (
); );
const updateTitle = (newNotificationsCount) => { const updateTitle = (newNotificationsCount) => {
document.title = document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
}; };
export default App; export default App;

View File

@ -16,11 +16,7 @@ const AvatarBox = (props) => {
height: "100vh", height: "100vh",
}} }}
> >
<Avatar <Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />
sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }}
src={logo}
variant="rounded"
/>
{props.children} {props.children}
</Box> </Box>
); );

View File

@ -17,8 +17,7 @@ import { useTranslation } from "react-i18next";
// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported. // This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported.
const emojisByCategory = {}; const emojisByCategory = {};
const isDesktopChrome = const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);
/Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);
const maxSupportedVersionForDesktopChrome = 11; const maxSupportedVersionForDesktopChrome = 11;
rawEmojis.forEach((emoji) => { rawEmojis.forEach((emoji) => {
if (!emojisByCategory[emoji.category]) { if (!emojisByCategory[emoji.category]) {
@ -26,12 +25,9 @@ rawEmojis.forEach((emoji) => {
} }
try { try {
const unicodeVersion = parseFloat(emoji.unicode_version); const unicodeVersion = parseFloat(emoji.unicode_version);
const supportedEmoji = const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
if (supportedEmoji) { if (supportedEmoji) {
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join( const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`;
" "
)} ${emoji.tags.join(" ")}`;
const emojiWithSearchBase = { ...emoji, searchBase: searchBase }; const emojiWithSearchBase = { ...emoji, searchBase: searchBase };
emojisByCategory[emoji.category].push(emojiWithSearchBase); emojisByCategory[emoji.category].push(emojiWithSearchBase);
} }
@ -53,13 +49,7 @@ const EmojiPicker = (props) => {
}; };
return ( return (
<Popper <Popper open={open} anchorEl={props.anchorEl} placement="bottom-start" sx={{ zIndex: 10005 }} transition>
open={open}
anchorEl={props.anchorEl}
placement="bottom-start"
sx={{ zIndex: 10005 }}
transition
>
{({ TransitionProps }) => ( {({ TransitionProps }) => (
<ClickAwayListener onClickAway={props.onClose}> <ClickAwayListener onClickAway={props.onClose}>
<Fade {...TransitionProps} timeout={350}> <Fade {...TransitionProps} timeout={350}>
@ -92,16 +82,8 @@ const EmojiPicker = (props) => {
}} }}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<InputAdornment <InputAdornment position="end" sx={{ display: search ? "" : "none" }}>
position="end" <IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}>
sx={{ display: search ? "" : "none" }}
>
<IconButton
size="small"
onClick={handleSearchClear}
edge="end"
aria-label={t("emoji_picker_search_clear")}
>
<Close /> <Close />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
@ -117,13 +99,7 @@ const EmojiPicker = (props) => {
}} }}
> >
{Object.keys(emojisByCategory).map((category) => ( {Object.keys(emojisByCategory).map((category) => (
<Category <Category key={category} title={category} emojis={emojisByCategory[category]} search={searchFields} onPick={props.onEmojiPick} />
key={category}
title={category}
emojis={emojisByCategory[category]}
search={searchFields}
onPick={props.onEmojiPick}
/>
))} ))}
</Box> </Box>
</Box> </Box>
@ -144,12 +120,7 @@ const Category = (props) => {
</Typography> </Typography>
)} )}
{props.emojis.map((emoji) => ( {props.emojis.map((emoji) => (
<Emoji <Emoji key={emoji.aliases[0]} emoji={emoji} search={props.search} onClick={() => props.onPick(emoji.aliases[0])} />
key={emoji.aliases[0]}
emoji={emoji}
search={props.search}
onClick={() => props.onPick(emoji.aliases[0])}
/>
))} ))}
</> </>
); );
@ -160,12 +131,7 @@ const Emoji = (props) => {
const matches = emojiMatches(emoji, props.search); const matches = emojiMatches(emoji, props.search);
const title = `${emoji.description} (${emoji.aliases[0]})`; const title = `${emoji.description} (${emoji.aliases[0]})`;
return ( return (
<EmojiDiv <EmojiDiv onClick={props.onClick} title={title} aria-label={title} style={{ display: matches ? "" : "none" }}>
onClick={props.onClick}
title={title}
aria-label={title}
style={{ display: matches ? "" : "none" }}
>
{props.emoji.emoji} {props.emoji.emoji}
</EmojiDiv> </EmojiDiv>
); );

View File

@ -22,9 +22,7 @@ class ErrorBoundaryImpl extends React.Component {
// - https://github.com/dexie/Dexie.js/issues/312 // - https://github.com/dexie/Dexie.js/issues/312
// - https://bugzilla.mozilla.org/show_bug.cgi?id=781982 // - https://bugzilla.mozilla.org/show_bug.cgi?id=781982
const isUnsupportedIndexedDB = const isUnsupportedIndexedDB =
error?.name === "InvalidStateError" || error?.name === "InvalidStateError" || (error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1);
(error?.name === "DatabaseClosedError" &&
error?.message?.indexOf("InvalidStateError") !== -1);
if (isUnsupportedIndexedDB) { if (isUnsupportedIndexedDB) {
this.handleUnsupportedIndexedDB(); this.handleUnsupportedIndexedDB();
@ -48,14 +46,7 @@ class ErrorBoundaryImpl extends React.Component {
// Fetch additional info and a better stack trace // Fetch additional info and a better stack trace
StackTrace.fromError(error).then((stack) => { StackTrace.fromError(error).then((stack) => {
console.error("[ErrorBoundary] Stacktrace fetched", stack); console.error("[ErrorBoundary] Stacktrace fetched", stack);
const niceStack = const niceStack = `${error.toString()}\n` + stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
`${error.toString()}\n` +
stack
.map(
(el) =>
` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`
)
.join("\n");
this.setState({ niceStack }); this.setState({ niceStack });
}); });
} }
@ -96,9 +87,7 @@ class ErrorBoundaryImpl extends React.Component {
<Trans <Trans
i18nKey="error_boundary_unsupported_indexeddb_description" i18nKey="error_boundary_unsupported_indexeddb_description"
components={{ components={{
githubLink: ( githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues/208" />,
<Link href="https://github.com/binwiederhier/ntfy/issues/208" />
),
discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />, discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />, matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />,
}} }}
@ -117,9 +106,7 @@ class ErrorBoundaryImpl extends React.Component {
<Trans <Trans
i18nKey="error_boundary_description" i18nKey="error_boundary_description"
components={{ components={{
githubLink: ( githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues" />,
<Link href="https://github.com/binwiederhier/ntfy/issues" />
),
discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />, discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />, matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />,
}} }}
@ -135,11 +122,7 @@ class ErrorBoundaryImpl extends React.Component {
<pre>{this.state.niceStack}</pre> <pre>{this.state.niceStack}</pre>
) : ( ) : (
<> <>
<CircularProgress <CircularProgress size="20px" sx={{ verticalAlign: "text-bottom" }} /> {t("error_boundary_gathering_info")}
size="20px"
sx={{ verticalAlign: "text-bottom" }}
/>{" "}
{t("error_boundary_gathering_info")}
</> </>
)} )}
<pre>{this.state.originalStack}</pre> <pre>{this.state.originalStack}</pre>

View File

@ -28,9 +28,7 @@ const Login = () => {
const user = { username, password }; const user = { username, password };
try { try {
const token = await accountApi.login(user); const token = await accountApi.login(user);
console.log( console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
`[Login] User auth for user ${user.username} successful, token is ${token}`
);
session.store(user.username, token); session.store(user.username, token);
window.location.href = routes.app; window.location.href = routes.app;
} catch (e) { } catch (e) {
@ -52,12 +50,7 @@ const Login = () => {
return ( return (
<AvatarBox> <AvatarBox>
<Typography sx={{ typography: "h6" }}>{t("login_title")}</Typography> <Typography sx={{ typography: "h6" }}>{t("login_title")}</Typography>
<Box <Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1, maxWidth: 400 }}>
component="form"
onSubmit={handleSubmit}
noValidate
sx={{ mt: 1, maxWidth: 400 }}
>
<TextField <TextField
margin="dense" margin="dense"
required required
@ -95,13 +88,7 @@ const Login = () => {
), ),
}} }}
/> />
<Button <Button type="submit" fullWidth variant="contained" disabled={username === "" || password === ""} sx={{ mt: 2, mb: 2 }}>
type="submit"
fullWidth
variant="contained"
disabled={username === "" || password === ""}
sx={{ mt: 2, mb: 2 }}
>
{t("login_form_button_submit")} {t("login_form_button_submit")}
</Button> </Button>
{error && ( {error && (

View File

@ -29,14 +29,7 @@ const Messaging = (props) => {
return ( return (
<> <>
{subscription && ( {subscription && <MessageBar subscription={subscription} message={message} onMessageChange={setMessage} onOpenDialogClick={handleOpenDialogClick} />}
<MessageBar
subscription={subscription}
message={message}
onMessageChange={setMessage}
onOpenDialogClick={handleOpenDialogClick}
/>
)}
<PublishDialog <PublishDialog
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
openMode={dialogOpenMode} openMode={dialogOpenMode}
@ -44,14 +37,8 @@ const Messaging = (props) => {
topic={subscription?.topic ?? ""} topic={subscription?.topic ?? ""}
message={message} message={message}
onClose={handleDialogClose} onClose={handleDialogClose}
onDragEnter={() => onDragEnter={() => props.onDialogOpenModeChange((prev) => (prev ? prev : PublishDialog.OPEN_MODE_DRAG))} // Only update if not already open
props.onDialogOpenModeChange((prev) => onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}
prev ? prev : PublishDialog.OPEN_MODE_DRAG
)
} // Only update if not already open
onResetOpenMode={() =>
props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)
}
/> />
</> </>
); );
@ -63,11 +50,7 @@ const MessageBar = (props) => {
const [snackOpen, setSnackOpen] = useState(false); const [snackOpen, setSnackOpen] = useState(false);
const handleSendClick = async () => { const handleSendClick = async () => {
try { try {
await api.publish( await api.publish(subscription.baseUrl, subscription.topic, props.message);
subscription.baseUrl,
subscription.topic,
props.message
);
} catch (e) { } catch (e) {
console.log(`[MessageBar] Error publishing message`, e); console.log(`[MessageBar] Error publishing message`, e);
setSnackOpen(true); setSnackOpen(true);
@ -84,19 +67,10 @@ const MessageBar = (props) => {
right: 0, right: 0,
padding: 2, padding: 2,
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` }, width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
backgroundColor: (theme) => backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]),
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}} }}
> >
<IconButton <IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick} aria-label={t("message_bar_show_dialog")}>
color="inherit"
size="large"
edge="start"
onClick={props.onOpenDialogClick}
aria-label={t("message_bar_show_dialog")}
>
<KeyboardArrowUpIcon /> <KeyboardArrowUpIcon />
</IconButton> </IconButton>
<TextField <TextField
@ -117,22 +91,11 @@ const MessageBar = (props) => {
} }
}} }}
/> />
<IconButton <IconButton color="inherit" size="large" edge="end" onClick={handleSendClick} aria-label={t("message_bar_publish")}>
color="inherit"
size="large"
edge="end"
onClick={handleSendClick}
aria-label={t("message_bar_publish")}
>
<SendIcon /> <SendIcon />
</IconButton> </IconButton>
<Portal> <Portal>
<Snackbar <Snackbar open={snackOpen} autoHideDuration={3000} onClose={() => setSnackOpen(false)} message={t("message_bar_error_publishing")} />
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("message_bar_error_publishing")}
/>
</Portal> </Portal>
</Paper> </Paper>
); );

View File

@ -12,16 +12,7 @@ import List from "@mui/material/List";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import SubscribeDialog from "./SubscribeDialog"; import SubscribeDialog from "./SubscribeDialog";
import { import { Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Portal, Tooltip } from "@mui/material";
Alert,
AlertTitle,
Badge,
CircularProgress,
Link,
ListSubheader,
Portal,
Tooltip,
} from "@mui/material";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { openUrl, topicDisplayName, topicUrl } from "../app/utils"; import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
@ -29,12 +20,7 @@ import routes from "./routes";
import { ConnectionState } from "../app/Connection"; import { ConnectionState } from "../app/Connection";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import { import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
ChatBubble,
MoreVert,
NotificationsOffOutlined,
Send,
} from "@mui/icons-material";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import notifier from "../app/Notifier"; import notifier from "../app/Notifier";
import config from "../app/config"; import config from "../app/config";
@ -45,12 +31,7 @@ import accountApi, { Permission, Role } from "../app/AccountApi";
import CelebrationIcon from "@mui/icons-material/Celebration"; import CelebrationIcon from "@mui/icons-material/Celebration";
import UpgradeDialog from "./UpgradeDialog"; import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App"; import { AccountContext } from "./App";
import { import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
PermissionDenyAll,
PermissionRead,
PermissionReadWrite,
PermissionWrite,
} from "./ReserveIcons";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { SubscriptionPopup } from "./SubscriptionPopup"; import { SubscriptionPopup } from "./SubscriptionPopup";
@ -59,11 +40,7 @@ const navWidth = 280;
const Navigation = (props) => { const Navigation = (props) => {
const navigationList = <NavList {...props} />; const navigationList = <NavList {...props} />;
return ( return (
<Box <Box component="nav" role="navigation" sx={{ width: { sm: Navigation.width }, flexShrink: { sm: 0 } }}>
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 */} {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
<Drawer <Drawer
variant="temporary" variant="temporary"
@ -109,19 +86,14 @@ const NavList = (props) => {
}; };
const handleSubscribeSubmit = (subscription) => { const handleSubscribeSubmit = (subscription) => {
console.log( console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
`[Navigation] New subscription: ${subscription.id}`,
subscription
);
handleSubscribeReset(); handleSubscribeReset();
navigate(routes.forSubscription(subscription)); navigate(routes.forSubscription(subscription));
handleRequestNotificationPermission(); handleRequestNotificationPermission();
}; };
const handleRequestNotificationPermission = () => { const handleRequestNotificationPermission = () => {
notifier.maybeRequestPermission((granted) => notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted));
props.onNotificationGranted(granted)
);
}; };
const handleAccountClick = () => { const handleAccountClick = () => {
@ -134,39 +106,19 @@ const NavList = (props) => {
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid; const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
const showSubscriptionsList = props.subscriptions?.length > 0; const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
const showNotificationContextNotSupportedBox = const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
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 showNotificationGrantBox = const navListPadding = showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox ? "0" : "";
notifier.supported() &&
props.subscriptions?.length > 0 &&
!props.notificationsGranted;
const navListPadding =
showNotificationGrantBox ||
showNotificationBrowserNotSupportedBox ||
showNotificationContextNotSupportedBox
? "0"
: "";
return ( return (
<> <>
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} /> <Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
<List component="nav" sx={{ paddingTop: navListPadding }}> <List component="nav" sx={{ paddingTop: navListPadding }}>
{showNotificationBrowserNotSupportedBox && ( {showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
<NotificationBrowserNotSupportedAlert /> {showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert />}
)} {showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission} />}
{showNotificationContextNotSupportedBox && (
<NotificationContextNotSupportedAlert />
)}
{showNotificationGrantBox && (
<NotificationGrantAlert
onRequestPermissionClick={handleRequestNotificationPermission}
/>
)}
{!showSubscriptionsList && ( {!showSubscriptionsList && (
<ListItemButton <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
onClick={() => navigate(routes.app)}
selected={location.pathname === config.app_root}
>
<ListItemIcon> <ListItemIcon>
<ChatBubble /> <ChatBubble />
</ListItemIcon> </ListItemIcon>
@ -176,37 +128,25 @@ const NavList = (props) => {
{showSubscriptionsList && ( {showSubscriptionsList && (
<> <>
<ListSubheader>{t("nav_topics_title")}</ListSubheader> <ListSubheader>{t("nav_topics_title")}</ListSubheader>
<ListItemButton <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
onClick={() => navigate(routes.app)}
selected={location.pathname === config.app_root}
>
<ListItemIcon> <ListItemIcon>
<ChatBubble /> <ChatBubble />
</ListItemIcon> </ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")} /> <ListItemText primary={t("nav_button_all_notifications")} />
</ListItemButton> </ListItemButton>
<SubscriptionList <SubscriptionList subscriptions={props.subscriptions} selectedSubscription={props.selectedSubscription} />
subscriptions={props.subscriptions}
selectedSubscription={props.selectedSubscription}
/>
<Divider sx={{ my: 1 }} /> <Divider sx={{ my: 1 }} />
</> </>
)} )}
{session.exists() && ( {session.exists() && (
<ListItemButton <ListItemButton onClick={handleAccountClick} selected={location.pathname === routes.account}>
onClick={handleAccountClick}
selected={location.pathname === routes.account}
>
<ListItemIcon> <ListItemIcon>
<Person /> <Person />
</ListItemIcon> </ListItemIcon>
<ListItemText primary={t("nav_button_account")} /> <ListItemText primary={t("nav_button_account")} />
</ListItemButton> </ListItemButton>
)} )}
<ListItemButton <ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
onClick={() => navigate(routes.settings)}
selected={location.pathname === routes.settings}
>
<ListItemIcon> <ListItemIcon>
<SettingsIcon /> <SettingsIcon />
</ListItemIcon> </ListItemIcon>
@ -260,8 +200,7 @@ const UpgradeBanner = () => {
width: `${Navigation.width - 1}px`, width: `${Navigation.width - 1}px`,
bottom: 0, bottom: 0,
mt: "auto", mt: "auto",
background: background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
"linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
}} }}
> >
<Divider /> <Divider />
@ -277,8 +216,7 @@ const UpgradeBanner = () => {
style: { style: {
fontWeight: 500, fontWeight: 500,
fontSize: "1.1rem", fontSize: "1.1rem",
background: background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
"-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
WebkitBackgroundClip: "text", WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent", WebkitTextFillColor: "transparent",
}, },
@ -290,11 +228,7 @@ const UpgradeBanner = () => {
}} }}
/> />
</ListItemButton> </ListItemButton>
<UpgradeDialog <UpgradeDialog key={`upgradeDialog${dialogKey}`} open={dialogOpen} onCancel={() => setDialogOpen(false)} />
key={`upgradeDialog${dialogKey}`}
open={dialogOpen}
onCancel={() => setDialogOpen(false)}
/>
</Box> </Box>
); );
}; };
@ -303,9 +237,7 @@ const SubscriptionList = (props) => {
const sortedSubscriptions = props.subscriptions const sortedSubscriptions = props.subscriptions
.filter((s) => !s.internal) .filter((s) => !s.internal)
.sort((a, b) => { .sort((a, b) => {
return topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) return topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1;
? -1
: 1;
}); });
return ( return (
<> <>
@ -313,10 +245,7 @@ const SubscriptionList = (props) => {
<SubscriptionItem <SubscriptionItem
key={subscription.id} key={subscription.id}
subscription={subscription} subscription={subscription}
selected={ selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id}
props.selectedSubscription &&
props.selectedSubscription.id === subscription.id
}
/> />
))} ))}
</> </>
@ -331,19 +260,12 @@ const SubscriptionItem = (props) => {
const subscription = props.subscription; const subscription = props.subscription;
const iconBadge = subscription.new <= 99 ? subscription.new : "99+"; const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
const displayName = topicDisplayName(subscription); const displayName = topicDisplayName(subscription);
const ariaLabel = const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName;
subscription.state === ConnectionState.Connecting
? `${displayName} (${t("nav_button_connecting")})`
: displayName;
const icon = const icon =
subscription.state === ConnectionState.Connecting ? ( subscription.state === ConnectionState.Connecting ? (
<CircularProgress size="24px" /> <CircularProgress size="24px" />
) : ( ) : (
<Badge <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary">
badgeContent={iconBadge}
invisible={subscription.new === 0}
color="primary"
>
<ChatBubbleOutlineIcon /> <ChatBubbleOutlineIcon />
</Badge> </Badge>
); );
@ -355,12 +277,7 @@ const SubscriptionItem = (props) => {
return ( return (
<> <>
<ListItemButton <ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
onClick={handleClick}
selected={props.selected}
aria-label={ariaLabel}
aria-live="polite"
>
<ListItemIcon>{icon}</ListItemIcon> <ListItemIcon>{icon}</ListItemIcon>
<ListItemText <ListItemText
primary={displayName} primary={displayName}
@ -371,9 +288,7 @@ const SubscriptionItem = (props) => {
{subscription.reservation?.everyone && ( {subscription.reservation?.everyone && (
<ListItemIcon edge="end" sx={{ minWidth: "26px" }}> <ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
{subscription.reservation?.everyone === Permission.READ_WRITE && ( {subscription.reservation?.everyone === Permission.READ_WRITE && (
<Tooltip <Tooltip title={t("prefs_reservations_table_everyone_read_write")}>
title={t("prefs_reservations_table_everyone_read_write")}
>
<PermissionReadWrite size="small" /> <PermissionReadWrite size="small" />
</Tooltip> </Tooltip>
)} )}
@ -383,9 +298,7 @@ const SubscriptionItem = (props) => {
</Tooltip> </Tooltip>
)} )}
{subscription.reservation?.everyone === Permission.WRITE_ONLY && ( {subscription.reservation?.everyone === Permission.WRITE_ONLY && (
<Tooltip <Tooltip title={t("prefs_reservations_table_everyone_write_only")}>
title={t("prefs_reservations_table_everyone_write_only")}
>
<PermissionWrite size="small" /> <PermissionWrite size="small" />
</Tooltip> </Tooltip>
)} )}
@ -397,11 +310,7 @@ const SubscriptionItem = (props) => {
</ListItemIcon> </ListItemIcon>
)} )}
{subscription.mutedUntil > 0 && ( {subscription.mutedUntil > 0 && (
<ListItemIcon <ListItemIcon edge="end" sx={{ minWidth: "26px" }} aria-label={t("nav_button_muted")}>
edge="end"
sx={{ minWidth: "26px" }}
aria-label={t("nav_button_muted")}
>
<Tooltip title={t("nav_button_muted")}> <Tooltip title={t("nav_button_muted")}>
<NotificationsOffOutlined /> <NotificationsOffOutlined />
</Tooltip> </Tooltip>
@ -421,11 +330,7 @@ const SubscriptionItem = (props) => {
</ListItemIcon> </ListItemIcon>
</ListItemButton> </ListItemButton>
<Portal> <Portal>
<SubscriptionPopup <SubscriptionPopup subscription={subscription} anchor={menuAnchorEl} onClose={() => setMenuAnchorEl(null)} />
subscription={subscription}
anchor={menuAnchorEl}
onClose={() => setMenuAnchorEl(null)}
/>
</Portal> </Portal>
</> </>
); );
@ -438,12 +343,7 @@ const NotificationGrantAlert = (props) => {
<Alert severity="warning" sx={{ paddingTop: 2 }}> <Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_grant_title")}</AlertTitle> <AlertTitle>{t("alert_grant_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_grant_description")}</Typography> <Typography gutterBottom>{t("alert_grant_description")}</Typography>
<Button <Button sx={{ float: "right" }} color="inherit" size="small" onClick={props.onRequestPermissionClick}>
sx={{ float: "right" }}
color="inherit"
size="small"
onClick={props.onRequestPermissionClick}
>
{t("alert_grant_button")} {t("alert_grant_button")}
</Button> </Button>
</Alert> </Alert>
@ -458,9 +358,7 @@ const NotificationBrowserNotSupportedAlert = () => {
<> <>
<Alert severity="warning" sx={{ paddingTop: 2 }}> <Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle> <AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom> <Typography gutterBottom>{t("alert_not_supported_description")}</Typography>
{t("alert_not_supported_description")}
</Typography>
</Alert> </Alert>
<Divider /> <Divider />
</> </>
@ -477,13 +375,7 @@ const NotificationContextNotSupportedAlert = () => {
<Trans <Trans
i18nKey="alert_not_supported_context_description" i18nKey="alert_not_supported_context_description"
components={{ components={{
mdnLink: ( mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener" />,
<Link
href="https://developer.mozilla.org/en-US/docs/Web/API/notification"
target="_blank"
rel="noopener"
/>
),
}} }}
/> />
</Typography> </Typography>

View File

@ -1,16 +1,5 @@
import Container from "@mui/material/Container"; import Container from "@mui/material/Container";
import { import { ButtonBase, CardActions, CardContent, CircularProgress, Fade, Link, Modal, Snackbar, Stack, Tooltip } from "@mui/material";
ButtonBase,
CardActions,
CardContent,
CircularProgress,
Fade,
Link,
Modal,
Snackbar,
Stack,
Tooltip,
} from "@mui/material";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import * as React from "react"; import * as React from "react";
@ -29,11 +18,7 @@ import {
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import CheckIcon from "@mui/icons-material/Check"; import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
LightboxBackdrop,
Paragraph,
VerticallyCenteredContainer,
} from "./styles";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
@ -68,10 +53,7 @@ export const SingleSubscription = () => {
const AllSubscriptionsList = (props) => { const AllSubscriptionsList = (props) => {
const subscriptions = props.subscriptions; const subscriptions = props.subscriptions;
const notifications = useLiveQuery( const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
() => subscriptionManager.getAllNotifications(),
[]
);
if (notifications === null || notifications === undefined) { if (notifications === null || notifications === undefined) {
return <Loading />; return <Loading />;
} else if (subscriptions.length === 0) { } else if (subscriptions.length === 0) {
@ -79,33 +61,18 @@ const AllSubscriptionsList = (props) => {
} else if (notifications.length === 0) { } else if (notifications.length === 0) {
return <NoNotificationsWithoutSubscription subscriptions={subscriptions} />; return <NoNotificationsWithoutSubscription subscriptions={subscriptions} />;
} }
return ( return <NotificationList key="all" notifications={notifications} messageBar={false} />;
<NotificationList
key="all"
notifications={notifications}
messageBar={false}
/>
);
}; };
const SingleSubscriptionList = (props) => { const SingleSubscriptionList = (props) => {
const subscription = props.subscription; const subscription = props.subscription;
const notifications = useLiveQuery( const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
() => subscriptionManager.getNotifications(subscription.id),
[subscription]
);
if (notifications === null || notifications === undefined) { if (notifications === null || notifications === undefined) {
return <Loading />; return <Loading />;
} else if (notifications.length === 0) { } else if (notifications.length === 0) {
return <NoNotifications subscription={subscription} />; return <NoNotifications subscription={subscription} />;
} }
return ( return <NotificationList id={subscription.id} notifications={notifications} messageBar={true} />;
<NotificationList
id={subscription.id}
notifications={notifications}
messageBar={true}
/>
);
}; };
const NotificationList = (props) => { const NotificationList = (props) => {
@ -146,18 +113,9 @@ const NotificationList = (props) => {
> >
<Stack spacing={3}> <Stack spacing={3}>
{notifications.slice(0, count).map((notification) => ( {notifications.slice(0, count).map((notification) => (
<NotificationItem <NotificationItem key={notification.id} notification={notification} onShowSnack={() => setSnackOpen(true)} />
key={notification.id}
notification={notification}
onShowSnack={() => setSnackOpen(true)}
/>
))} ))}
<Snackbar <Snackbar open={snackOpen} autoHideDuration={3000} onClose={() => setSnackOpen(false)} message={t("notifications_copied_to_clipboard")} />
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("notifications_copied_to_clipboard")}
/>
</Stack> </Stack>
</Container> </Container>
</InfiniteScroll> </InfiniteScroll>
@ -176,45 +134,29 @@ const NotificationItem = (props) => {
await subscriptionManager.deleteNotification(notification.id); await subscriptionManager.deleteNotification(notification.id);
}; };
const handleMarkRead = async () => { const handleMarkRead = async () => {
console.log( console.log(`[Notifications] Marking notification ${notification.id} as read`);
`[Notifications] Marking notification ${notification.id} as read`
);
await subscriptionManager.markNotificationRead(notification.id); await subscriptionManager.markNotificationRead(notification.id);
}; };
const handleCopy = (s) => { const handleCopy = (s) => {
navigator.clipboard.writeText(s); navigator.clipboard.writeText(s);
props.onShowSnack(); props.onShowSnack();
}; };
const expired = const expired = attachment && attachment.expires && attachment.expires < Date.now() / 1000;
attachment && attachment.expires && attachment.expires < Date.now() / 1000;
const hasAttachmentActions = attachment && !expired; const hasAttachmentActions = attachment && !expired;
const hasClickAction = notification.click; const hasClickAction = notification.click;
const hasUserActions = const hasUserActions = notification.actions && notification.actions.length > 0;
notification.actions && notification.actions.length > 0;
const showActions = hasAttachmentActions || hasClickAction || hasUserActions; const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
return ( return (
<Card <Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
sx={{ minWidth: 275, padding: 1 }}
role="listitem"
aria-label={t("notifications_list_item")}
>
<CardContent> <CardContent>
<Tooltip title={t("notifications_delete")} enterDelay={500}> <Tooltip title={t("notifications_delete")} enterDelay={500}>
<IconButton <IconButton onClick={handleDelete} sx={{ float: "right", marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}>
onClick={handleDelete}
sx={{ float: "right", marginRight: -1, marginTop: -1 }}
aria-label={t("notifications_delete")}
>
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{notification.new === 1 && ( {notification.new === 1 && (
<Tooltip title={t("notifications_mark_read")} enterDelay={500}> <Tooltip title={t("notifications_mark_read")} enterDelay={500}>
<IconButton <IconButton onClick={handleMarkRead} sx={{ float: "right", marginRight: -0.5, marginTop: -1 }} aria-label={t("notifications_mark_read")}>
onClick={handleMarkRead}
sx={{ float: "right", marginRight: -0.5, marginTop: -1 }}
aria-label={t("notifications_mark_read")}
>
<CheckIcon /> <CheckIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@ -247,9 +189,7 @@ const NotificationItem = (props) => {
</Typography> </Typography>
)} )}
<Typography variant="body1" sx={{ whiteSpace: "pre-line" }}> <Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
{autolink( {autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
maybeAppendActionErrors(formatMessage(notification), notification)
)}
</Typography> </Typography>
{attachment && <Attachment attachment={attachment} />} {attachment && <Attachment attachment={attachment} />}
{tags && ( {tags && (
@ -263,36 +203,28 @@ const NotificationItem = (props) => {
{hasAttachmentActions && ( {hasAttachmentActions && (
<> <>
<Tooltip title={t("notifications_attachment_copy_url_title")}> <Tooltip title={t("notifications_attachment_copy_url_title")}>
<Button onClick={() => handleCopy(attachment.url)}> <Button onClick={() => handleCopy(attachment.url)}>{t("notifications_attachment_copy_url_button")}</Button>
{t("notifications_attachment_copy_url_button")}
</Button>
</Tooltip> </Tooltip>
<Tooltip <Tooltip
title={t("notifications_attachment_open_title", { title={t("notifications_attachment_open_title", {
url: attachment.url, url: attachment.url,
})} })}
> >
<Button onClick={() => openUrl(attachment.url)}> <Button onClick={() => openUrl(attachment.url)}>{t("notifications_attachment_open_button")}</Button>
{t("notifications_attachment_open_button")}
</Button>
</Tooltip> </Tooltip>
</> </>
)} )}
{hasClickAction && ( {hasClickAction && (
<> <>
<Tooltip title={t("notifications_click_copy_url_title")}> <Tooltip title={t("notifications_click_copy_url_title")}>
<Button onClick={() => handleCopy(notification.click)}> <Button onClick={() => handleCopy(notification.click)}>{t("notifications_click_copy_url_button")}</Button>
{t("notifications_click_copy_url_button")}
</Button>
</Tooltip> </Tooltip>
<Tooltip <Tooltip
title={t("notifications_actions_open_url_title", { title={t("notifications_actions_open_url_title", {
url: notification.click, url: notification.click,
})} })}
> >
<Button onClick={() => openUrl(notification.click)}> <Button onClick={() => openUrl(notification.click)}>{t("notifications_click_open_button")}</Button>
{t("notifications_click_open_button")}
</Button>
</Tooltip> </Tooltip>
</> </>
)} )}
@ -311,18 +243,10 @@ const NotificationItem = (props) => {
* [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9 * [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9
*/ */
const autolink = (s) => { const autolink = (s) => {
const parts = s.split( const parts = s.split(/(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi);
/(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi
);
for (let i = 1; i < parts.length; i += 2) { for (let i = 1; i < parts.length; i += 2) {
parts[i] = ( parts[i] = (
<Link <Link key={i} href={parts[i]} underline="hover" target="_blank" rel="noreferrer,noopener">
key={i}
href={parts[i]}
underline="hover"
target="_blank"
rel="noreferrer,noopener"
>
{shortUrl(parts[i])} {shortUrl(parts[i])}
</Link> </Link>
); );
@ -342,8 +266,7 @@ const Attachment = (props) => {
const attachment = props.attachment; const attachment = props.attachment;
const expired = attachment.expires && attachment.expires < Date.now() / 1000; const expired = attachment.expires && attachment.expires < Date.now() / 1000;
const expires = attachment.expires && attachment.expires > Date.now() / 1000; const expires = attachment.expires && attachment.expires > Date.now() / 1000;
const displayableImage = const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
!expired && attachment.type && attachment.type.startsWith("image/");
// Unexpired image // Unexpired image
if (displayableImage) { if (displayableImage) {
@ -386,10 +309,7 @@ const Attachment = (props) => {
}} }}
> >
<AttachmentIcon type={attachment.type} /> <AttachmentIcon type={attachment.type} />
<Typography <Typography variant="body2" sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}>
variant="body2"
sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}
>
<b>{attachment.name}</b> <b>{attachment.name}</b>
{maybeInfoText} {maybeInfoText}
</Typography> </Typography>
@ -420,10 +340,7 @@ const Attachment = (props) => {
}} }}
> >
<AttachmentIcon type={attachment.type} /> <AttachmentIcon type={attachment.type} />
<Typography <Typography variant="body2" sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}>
variant="body2"
sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}
>
<b>{attachment.name}</b> <b>{attachment.name}</b>
{maybeInfoText} {maybeInfoText}
</Typography> </Typography>
@ -453,11 +370,7 @@ const Image = (props) => {
cursor: "pointer", cursor: "pointer",
}} }}
/> />
<Modal <Modal open={open} onClose={() => setOpen(false)} BackdropComponent={LightboxBackdrop}>
open={open}
onClose={() => setOpen(false)}
BackdropComponent={LightboxBackdrop}
>
<Fade in={open}> <Fade in={open}>
<Box <Box
component="img" component="img"
@ -484,11 +397,7 @@ const UserActions = (props) => {
return ( return (
<> <>
{props.notification.actions.map((action) => ( {props.notification.actions.map((action) => (
<UserAction <UserAction key={action.id} notification={props.notification} action={action} />
key={action.id}
notification={props.notification}
action={action}
/>
))} ))}
</> </>
); );
@ -502,10 +411,7 @@ const UserAction = (props) => {
return ( return (
<Tooltip title={t("notifications_actions_not_supported")}> <Tooltip title={t("notifications_actions_not_supported")}>
<span> <span>
<Button <Button disabled aria-label={t("notifications_actions_not_supported")}>
disabled
aria-label={t("notifications_actions_not_supported")}
>
{action.label} {action.label}
</Button> </Button>
</span> </span>
@ -513,9 +419,7 @@ const UserAction = (props) => {
); );
} else if (action.action === "view") { } else if (action.action === "view") {
return ( return (
<Tooltip <Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
title={t("notifications_actions_open_url_title", { url: action.url })}
>
<Button <Button
onClick={() => openUrl(action.url)} onClick={() => openUrl(action.url)}
aria-label={t("notifications_actions_open_url_title", { aria-label={t("notifications_actions_open_url_title", {
@ -528,8 +432,7 @@ const UserAction = (props) => {
); );
} else if (action.action === "http") { } else if (action.action === "http") {
const method = action.method ?? "POST"; const method = action.method ?? "POST";
const label = const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
return ( return (
<Tooltip <Tooltip
title={t("notifications_actions_http_request_title", { title={t("notifications_actions_http_request_title", {
@ -568,21 +471,11 @@ const performHttpAction = async (notification, action) => {
if (success) { if (success) {
updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null); updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null);
} else { } else {
updateActionStatus( updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`);
notification,
action,
ACTION_PROGRESS_FAILED,
`${action.label}: Unexpected response HTTP ${response.status}`
);
} }
} catch (e) { } catch (e) {
console.log(`[Notifications] HTTP action failed`, e); console.log(`[Notifications] HTTP action failed`, e);
updateActionStatus( updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`);
notification,
action,
ACTION_PROGRESS_FAILED,
`${action.label}: ${e} Check developer console for details.`
);
} }
}; };
@ -608,19 +501,11 @@ const ACTION_LABEL_SUFFIX = {
const NoNotifications = (props) => { const NoNotifications = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const shortUrl = topicShortUrl( const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
props.subscription.baseUrl,
props.subscription.topic
);
return ( return (
<VerticallyCenteredContainer maxWidth="xs"> <VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img <img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")} />
src={logoOutline}
height="64"
width="64"
alt={t("action_bar_logo_alt")}
/>
<br /> <br />
{t("notifications_none_for_topic_title")} {t("notifications_none_for_topic_title")}
</Typography> </Typography>
@ -643,12 +528,7 @@ const NoNotificationsWithoutSubscription = (props) => {
return ( return (
<VerticallyCenteredContainer maxWidth="xs"> <VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img <img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")} />
src={logoOutline}
height="64"
width="64"
alt={t("action_bar_logo_alt")}
/>
<br /> <br />
{t("notifications_none_for_any_title")} {t("notifications_none_for_any_title")}
</Typography> </Typography>
@ -669,12 +549,7 @@ const NoSubscriptions = () => {
return ( return (
<VerticallyCenteredContainer maxWidth="xs"> <VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img <img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")} />
src={logoOutline}
height="64"
width="64"
alt={t("action_bar_logo_alt")}
/>
<br /> <br />
{t("notifications_no_subscriptions_title")} {t("notifications_no_subscriptions_title")}
</Typography> </Typography>
@ -695,12 +570,8 @@ const ForMoreDetails = () => {
<Trans <Trans
i18nKey="notifications_more_details" i18nKey="notifications_more_details"
components={{ components={{
websiteLink: ( websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener" />,
<Link href="https://ntfy.sh" target="_blank" rel="noopener" /> docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />,
),
docsLink: (
<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />
),
}} }}
/> />
); );
@ -710,12 +581,7 @@ const Loading = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<VerticallyCenteredContainer> <VerticallyCenteredContainer>
<Typography <Typography variant="h5" color="text.secondary" align="center" sx={{ paddingBottom: 1 }}>
variant="h5"
color="text.secondary"
align="center"
sx={{ paddingBottom: 1 }}
>
<CircularProgress disableShrink sx={{ marginBottom: 1 }} /> <CircularProgress disableShrink sx={{ marginBottom: 1 }} />
<br /> <br />
{t("notifications_loading")} {t("notifications_loading")}

View File

@ -44,17 +44,8 @@ import { Pref, PrefGroup } from "./Pref";
import { Info } from "@mui/icons-material"; import { Info } from "@mui/icons-material";
import { AccountContext } from "./App"; import { AccountContext } from "./App";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
import { import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
PermissionDenyAll, import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
PermissionRead,
PermissionReadWrite,
PermissionWrite,
} from "./ReserveIcons";
import {
ReserveAddDialog,
ReserveDeleteDialog,
ReserveEditDialog,
} from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors"; import { UnauthorizedError } from "../app/errors";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import { subscribeTopic } from "./SubscribeDialog"; import { subscribeTopic } from "./SubscribeDialog";
@ -112,21 +103,11 @@ const Sound = () => {
}); });
} }
return ( return (
<Pref <Pref labelId={labelId} title={t("prefs_notifications_sound_title")} description={description}>
labelId={labelId}
title={t("prefs_notifications_sound_title")}
description={description}
>
<div style={{ display: "flex", width: "100%" }}> <div style={{ display: "flex", width: "100%" }}>
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}> <FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
<Select <Select value={sound} onChange={handleChange} aria-labelledby={labelId}>
value={sound} <MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem>
onChange={handleChange}
aria-labelledby={labelId}
>
<MenuItem value={"none"}>
{t("prefs_notifications_sound_no_sound")}
</MenuItem>
{Object.entries(sounds).map((s) => ( {Object.entries(sounds).map((s) => (
<MenuItem key={s[0]} value={s[0]}> <MenuItem key={s[0]} value={s[0]}>
{s[1].label} {s[1].label}
@ -134,11 +115,7 @@ const Sound = () => {
))} ))}
</Select> </Select>
</FormControl> </FormControl>
<IconButton <IconButton onClick={() => playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}>
onClick={() => playSound(sound)}
disabled={sound === "none"}
aria-label={t("prefs_notifications_sound_play")}
>
<PlayArrowIcon /> <PlayArrowIcon />
</IconButton> </IconButton>
</div> </div>
@ -174,41 +151,20 @@ const MinPriority = () => {
} else if (minPriority === 5) { } else if (minPriority === 5) {
description = t("prefs_notifications_min_priority_description_max"); description = t("prefs_notifications_min_priority_description_max");
} else { } else {
description = t( description = t("prefs_notifications_min_priority_description_x_or_higher", {
"prefs_notifications_min_priority_description_x_or_higher", number: minPriority,
{ name: priorities[minPriority],
number: minPriority, });
name: priorities[minPriority],
}
);
} }
return ( return (
<Pref <Pref labelId={labelId} title={t("prefs_notifications_min_priority_title")} description={description}>
labelId={labelId}
title={t("prefs_notifications_min_priority_title")}
description={description}
>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}> <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select <Select value={minPriority} onChange={handleChange} aria-labelledby={labelId}>
value={minPriority} <MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem>
onChange={handleChange} <MenuItem value={2}>{t("prefs_notifications_min_priority_low_and_higher")}</MenuItem>
aria-labelledby={labelId} <MenuItem value={3}>{t("prefs_notifications_min_priority_default_and_higher")}</MenuItem>
> <MenuItem value={4}>{t("prefs_notifications_min_priority_high_and_higher")}</MenuItem>
<MenuItem value={1}> <MenuItem value={5}>{t("prefs_notifications_min_priority_max_only")}</MenuItem>
{t("prefs_notifications_min_priority_any")}
</MenuItem>
<MenuItem value={2}>
{t("prefs_notifications_min_priority_low_and_higher")}
</MenuItem>
<MenuItem value={3}>
{t("prefs_notifications_min_priority_default_and_higher")}
</MenuItem>
<MenuItem value={4}>
{t("prefs_notifications_min_priority_high_and_higher")}
</MenuItem>
<MenuItem value={5}>
{t("prefs_notifications_min_priority_max_only")}
</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</Pref> </Pref>
@ -246,32 +202,14 @@ const DeleteAfter = () => {
} }
})(); })();
return ( return (
<Pref <Pref labelId={labelId} title={t("prefs_notifications_delete_after_title")} description={description}>
labelId={labelId}
title={t("prefs_notifications_delete_after_title")}
description={description}
>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}> <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select <Select value={deleteAfter} onChange={handleChange} aria-labelledby={labelId}>
value={deleteAfter} <MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem>
onChange={handleChange} <MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem>
aria-labelledby={labelId} <MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem>
> <MenuItem value={604800}>{t("prefs_notifications_delete_after_one_week")}</MenuItem>
<MenuItem value={0}> <MenuItem value={2592000}>{t("prefs_notifications_delete_after_one_month")}</MenuItem>
{t("prefs_notifications_delete_after_never")}
</MenuItem>
<MenuItem value={10800}>
{t("prefs_notifications_delete_after_three_hours")}
</MenuItem>
<MenuItem value={86400}>
{t("prefs_notifications_delete_after_one_day")}
</MenuItem>
<MenuItem value={604800}>
{t("prefs_notifications_delete_after_one_week")}
</MenuItem>
<MenuItem value={2592000}>
{t("prefs_notifications_delete_after_one_month")}
</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</Pref> </Pref>
@ -294,9 +232,7 @@ const Users = () => {
setDialogOpen(false); setDialogOpen(false);
try { try {
await userManager.save(user); await userManager.save(user);
console.debug( console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`);
`[Preferences] User ${user.username} for ${user.baseUrl} added`
);
} catch (e) { } catch (e) {
console.log(`[Preferences] Error adding user.`, e); console.log(`[Preferences] Error adding user.`, e);
} }
@ -309,22 +245,13 @@ const Users = () => {
</Typography> </Typography>
<Paragraph> <Paragraph>
{t("prefs_users_description")} {t("prefs_users_description")}
{session.exists() && ( {session.exists() && <>{" " + t("prefs_users_description_no_sync")}</>}
<>{" " + t("prefs_users_description_no_sync")}</>
)}
</Paragraph> </Paragraph>
{users?.length > 0 && <UserTable users={users} />} {users?.length > 0 && <UserTable users={users} />}
</CardContent> </CardContent>
<CardActions> <CardActions>
<Button onClick={handleAddClick}>{t("prefs_users_add_button")}</Button> <Button onClick={handleAddClick}>{t("prefs_users_add_button")}</Button>
<UserDialog <UserDialog key={`userAddDialog${dialogKey}`} open={dialogOpen} user={null} users={users} onCancel={handleDialogCancel} onSubmit={handleDialogSubmit} />
key={`userAddDialog${dialogKey}`}
open={dialogOpen}
user={null}
users={users}
onCancel={handleDialogCancel}
onSubmit={handleDialogSubmit}
/>
</CardActions> </CardActions>
</Card> </Card>
); );
@ -350,9 +277,7 @@ const UserTable = (props) => {
setDialogOpen(false); setDialogOpen(false);
try { try {
await userManager.save(user); await userManager.save(user);
console.debug( console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`);
`[Preferences] User ${user.username} for ${user.baseUrl} updated`
);
} catch (e) { } catch (e) {
console.log(`[Preferences] Error updating user.`, e); console.log(`[Preferences] Error updating user.`, e);
} }
@ -361,9 +286,7 @@ const UserTable = (props) => {
const handleDeleteClick = async (user) => { const handleDeleteClick = async (user) => {
try { try {
await userManager.delete(user.baseUrl); await userManager.delete(user.baseUrl);
console.debug( console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`);
`[Preferences] User ${user.username} for ${user.baseUrl} deleted`
);
} catch (e) { } catch (e) {
console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e); console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
} }
@ -373,43 +296,25 @@ const UserTable = (props) => {
<Table size="small" aria-label={t("prefs_users_table")}> <Table size="small" aria-label={t("prefs_users_table")}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell sx={{ paddingLeft: 0 }}> <TableCell sx={{ paddingLeft: 0 }}>{t("prefs_users_table_user_header")}</TableCell>
{t("prefs_users_table_user_header")}
</TableCell>
<TableCell>{t("prefs_users_table_base_url_header")}</TableCell> <TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
<TableCell /> <TableCell />
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{props.users?.map((user) => ( {props.users?.map((user) => (
<TableRow <TableRow key={user.baseUrl} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
key={user.baseUrl} <TableCell component="th" scope="row" sx={{ paddingLeft: 0 }} aria-label={t("prefs_users_table_user_header")}>
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell
component="th"
scope="row"
sx={{ paddingLeft: 0 }}
aria-label={t("prefs_users_table_user_header")}
>
{user.username} {user.username}
</TableCell> </TableCell>
<TableCell aria-label={t("prefs_users_table_base_url_header")}> <TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
{user.baseUrl}
</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}> <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{(!session.exists() || user.baseUrl !== config.base_url) && ( {(!session.exists() || user.baseUrl !== config.base_url) && (
<> <>
<IconButton <IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
onClick={() => handleEditClick(user)}
aria-label={t("prefs_users_edit_button")}
>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
<IconButton <IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
onClick={() => handleDeleteClick(user)}
aria-label={t("prefs_users_delete_button")}
>
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
</> </>
@ -454,15 +359,8 @@ const UserDialog = (props) => {
return username.length > 0 && password.length > 0; return username.length > 0 && password.length > 0;
} }
const baseUrlValid = validUrl(baseUrl); const baseUrlValid = validUrl(baseUrl);
const baseUrlExists = props.users const baseUrlExists = props.users?.map((user) => user.baseUrl).includes(baseUrl);
?.map((user) => user.baseUrl) return baseUrlValid && !baseUrlExists && username.length > 0 && password.length > 0;
.includes(baseUrl);
return (
baseUrlValid &&
!baseUrlExists &&
username.length > 0 &&
password.length > 0
);
})(); })();
const handleSubmit = async () => { const handleSubmit = async () => {
props.onSubmit({ props.onSubmit({
@ -480,11 +378,7 @@ const UserDialog = (props) => {
}, [editMode, props.user]); }, [editMode, props.user]);
return ( return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}> <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle> <DialogTitle>{editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")}</DialogTitle>
{editMode
? t("prefs_users_dialog_title_edit")
: t("prefs_users_dialog_title_add")}
</DialogTitle>
<DialogContent> <DialogContent>
{!editMode && ( {!editMode && (
<TextField <TextField
@ -555,26 +449,7 @@ const Language = () => {
// Country flags are displayed using emoji. Emoji rendering is handled by platform fonts. // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts.
// Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows. // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows.
const randomFlags = shuffle([ const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇵🇱", "🇺🇦", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
"🇬🇧",
"🇺🇸",
"🇪🇸",
"🇫🇷",
"🇧🇬",
"🇨🇿",
"🇩🇪",
"🇵🇱",
"🇺🇦",
"🇨🇳",
"🇮🇹",
"🇭🇺",
"🇧🇷",
"🇳🇱",
"🇮🇩",
"🇯🇵",
"🇷🇺",
"🇹🇷",
]).slice(0, 3);
const showFlags = !navigator.userAgent.includes("Windows"); const showFlags = !navigator.userAgent.includes("Windows");
let title = t("prefs_appearance_language_title"); let title = t("prefs_appearance_language_title");
if (showFlags) { if (showFlags) {
@ -635,8 +510,7 @@ const Reservations = () => {
return <></>; return <></>;
} }
const reservations = account.reservations || []; const reservations = account.reservations || [];
const limitReached = const limitReached = account.role === Role.USER && account.stats.reservations_remaining === 0;
account.role === Role.USER && account.stats.reservations_remaining === 0;
const handleAddClick = () => { const handleAddClick = () => {
setDialogKey((prev) => prev + 1); setDialogKey((prev) => prev + 1);
@ -650,23 +524,14 @@ const Reservations = () => {
{t("prefs_reservations_title")} {t("prefs_reservations_title")}
</Typography> </Typography>
<Paragraph>{t("prefs_reservations_description")}</Paragraph> <Paragraph>{t("prefs_reservations_description")}</Paragraph>
{reservations.length > 0 && ( {reservations.length > 0 && <ReservationsTable reservations={reservations} />}
<ReservationsTable reservations={reservations} /> {limitReached && <Alert severity="info">{t("prefs_reservations_limit_reached")}</Alert>}
)}
{limitReached && (
<Alert severity="info">{t("prefs_reservations_limit_reached")}</Alert>
)}
</CardContent> </CardContent>
<CardActions> <CardActions>
<Button onClick={handleAddClick} disabled={limitReached}> <Button onClick={handleAddClick} disabled={limitReached}>
{t("prefs_reservations_add_button")} {t("prefs_reservations_add_button")}
</Button> </Button>
<ReserveAddDialog <ReserveAddDialog key={`reservationAddDialog${dialogKey}`} open={dialogOpen} reservations={reservations} onClose={() => setDialogOpen(false)} />
key={`reservationAddDialog${dialogKey}`}
open={dialogOpen}
reservations={reservations}
onClose={() => setDialogOpen(false)}
/>
</CardActions> </CardActions>
</Card> </Card>
); );
@ -680,14 +545,7 @@ const ReservationsTable = (props) => {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { subscriptions } = useOutletContext(); const { subscriptions } = useOutletContext();
const localSubscriptions = const localSubscriptions =
subscriptions?.length > 0 subscriptions?.length > 0 ? Object.assign({}, ...subscriptions.filter((s) => s.baseUrl === config.base_url).map((s) => ({ [s.topic]: s }))) : {};
? Object.assign(
{},
...subscriptions
.filter((s) => s.baseUrl === config.base_url)
.map((s) => ({ [s.topic]: s }))
)
: {};
const handleEditClick = (reservation) => { const handleEditClick = (reservation) => {
setDialogKey((prev) => prev + 1); setDialogKey((prev) => prev + 1);
@ -709,70 +567,46 @@ const ReservationsTable = (props) => {
<Table size="small" aria-label={t("prefs_reservations_table")}> <Table size="small" aria-label={t("prefs_reservations_table")}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell sx={{ paddingLeft: 0 }}> <TableCell sx={{ paddingLeft: 0 }}>{t("prefs_reservations_table_topic_header")}</TableCell>
{t("prefs_reservations_table_topic_header")}
</TableCell>
<TableCell>{t("prefs_reservations_table_access_header")}</TableCell> <TableCell>{t("prefs_reservations_table_access_header")}</TableCell>
<TableCell /> <TableCell />
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{props.reservations.map((reservation) => ( {props.reservations.map((reservation) => (
<TableRow <TableRow key={reservation.topic} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
key={reservation.topic} <TableCell component="th" scope="row" sx={{ paddingLeft: 0 }} aria-label={t("prefs_reservations_table_topic_header")}>
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell
component="th"
scope="row"
sx={{ paddingLeft: 0 }}
aria-label={t("prefs_reservations_table_topic_header")}
>
{reservation.topic} {reservation.topic}
</TableCell> </TableCell>
<TableCell aria-label={t("prefs_reservations_table_access_header")}> <TableCell aria-label={t("prefs_reservations_table_access_header")}>
{reservation.everyone === Permission.READ_WRITE && ( {reservation.everyone === Permission.READ_WRITE && (
<> <>
<PermissionReadWrite <PermissionReadWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }} />
size="small"
sx={{ verticalAlign: "bottom", mr: 1.5 }}
/>
{t("prefs_reservations_table_everyone_read_write")} {t("prefs_reservations_table_everyone_read_write")}
</> </>
)} )}
{reservation.everyone === Permission.READ_ONLY && ( {reservation.everyone === Permission.READ_ONLY && (
<> <>
<PermissionRead <PermissionRead size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }} />
size="small"
sx={{ verticalAlign: "bottom", mr: 1.5 }}
/>
{t("prefs_reservations_table_everyone_read_only")} {t("prefs_reservations_table_everyone_read_only")}
</> </>
)} )}
{reservation.everyone === Permission.WRITE_ONLY && ( {reservation.everyone === Permission.WRITE_ONLY && (
<> <>
<PermissionWrite <PermissionWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }} />
size="small"
sx={{ verticalAlign: "bottom", mr: 1.5 }}
/>
{t("prefs_reservations_table_everyone_write_only")} {t("prefs_reservations_table_everyone_write_only")}
</> </>
)} )}
{reservation.everyone === Permission.DENY_ALL && ( {reservation.everyone === Permission.DENY_ALL && (
<> <>
<PermissionDenyAll <PermissionDenyAll size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }} />
size="small"
sx={{ verticalAlign: "bottom", mr: 1.5 }}
/>
{t("prefs_reservations_table_everyone_deny_all")} {t("prefs_reservations_table_everyone_deny_all")}
</> </>
)} )}
</TableCell> </TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}> <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{!localSubscriptions[reservation.topic] && ( {!localSubscriptions[reservation.topic] && (
<Tooltip <Tooltip title={t("prefs_reservations_table_click_to_subscribe")}>
title={t("prefs_reservations_table_click_to_subscribe")}
>
<Chip <Chip
icon={<Info />} icon={<Info />}
onClick={() => handleSubscribeClick(reservation)} onClick={() => handleSubscribeClick(reservation)}
@ -782,16 +616,10 @@ const ReservationsTable = (props) => {
/> />
</Tooltip> </Tooltip>
)} )}
<IconButton <IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
onClick={() => handleEditClick(reservation)}
aria-label={t("prefs_reservations_edit_button")}
>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
<IconButton <IconButton onClick={() => handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}>
onClick={() => handleDeleteClick(reservation)}
aria-label={t("prefs_reservations_delete_button")}
>
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
</TableCell> </TableCell>

View File

@ -1,17 +1,7 @@
import * as React from "react"; import * as React from "react";
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState } from "react";
import theme from "./theme"; import theme from "./theme";
import { import { Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, Tooltip, useMediaQuery } from "@mui/material";
Checkbox,
Chip,
FormControl,
FormControlLabel,
InputLabel,
Link,
Select,
Tooltip,
useMediaQuery,
} from "@mui/material";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import priority1 from "../img/priority-1.svg"; import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg"; import priority2 from "../img/priority-2.svg";
@ -27,14 +17,7 @@ import IconButton from "@mui/material/IconButton";
import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon"; import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
import { Close } from "@mui/icons-material"; import { Close } from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import { import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils";
formatBytes,
maybeWithAuth,
topicShortUrl,
topicUrl,
validTopic,
validUrl,
} from "../app/utils";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import AttachmentIcon from "./AttachmentIcon"; import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
@ -152,10 +135,7 @@ const PublishDialog = (props) => {
url.searchParams.append("delay", delay.trim()); url.searchParams.append("delay", delay.trim());
} }
if (attachFile && message.trim()) { if (attachFile && message.trim()) {
url.searchParams.append( url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
"message",
message.replaceAll("\n", "\\n").trim()
);
} }
const body = attachFile ? attachFile : message; const body = attachFile ? attachFile : message;
try { try {
@ -184,11 +164,7 @@ const PublishDialog = (props) => {
setActiveRequest(null); setActiveRequest(null);
} }
} catch (e) { } catch (e) {
setStatus( setStatus(<Typography sx={{ color: "error.main", maxWidth: "400px" }}>{e}</Typography>);
<Typography sx={{ color: "error.main", maxWidth: "400px" }}>
{e}
</Typography>
);
setActiveRequest(null); setActiveRequest(null);
} }
}; };
@ -198,8 +174,7 @@ const PublishDialog = (props) => {
const account = await accountApi.get(); const account = await accountApi.get();
const fileSizeLimit = account.limits.attachment_file_size ?? 0; const fileSizeLimit = account.limits.attachment_file_size ?? 0;
const remainingBytes = account.stats.attachment_total_size_remaining; const remainingBytes = account.stats.attachment_total_size_remaining;
const fileSizeLimitReached = const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;
fileSizeLimit > 0 && file.size > fileSizeLimit;
const quotaReached = remainingBytes > 0 && file.size > remainingBytes; const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
if (fileSizeLimitReached && quotaReached) { if (fileSizeLimitReached && quotaReached) {
return setAttachFileError( return setAttachFileError(
@ -282,18 +257,8 @@ const PublishDialog = (props) => {
return ( return (
<> <>
{dropZone && ( {dropZone && <DropArea onDrop={handleAttachFileDrop} onDragLeave={handleAttachFileDragLeave} />}
<DropArea <Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
onDrop={handleAttachFileDrop}
onDragLeave={handleAttachFileDragLeave}
/>
)}
<Dialog
maxWidth="md"
open={open}
onClose={props.onCancel}
fullScreen={fullScreen}
>
<DialogTitle> <DialogTitle>
{baseUrl && topic {baseUrl && topic
? t("publish_dialog_title_topic", { ? t("publish_dialog_title_topic", {
@ -377,16 +342,8 @@ const PublishDialog = (props) => {
}} }}
/> />
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
<EmojiPicker <EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} />
anchorEl={emojiPickerAnchorEl} <DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
onEmojiPick={handleEmojiPick}
onClose={handleEmojiClose}
/>
<DialogIconButton
disabled={disabled}
onClick={handleEmojiClick}
aria-label={t("publish_dialog_emoji_picker_show")}
>
<InsertEmoticonIcon /> <InsertEmoticonIcon />
</DialogIconButton> </DialogIconButton>
<TextField <TextField
@ -403,11 +360,7 @@ const PublishDialog = (props) => {
"aria-label": t("publish_dialog_tags_label"), "aria-label": t("publish_dialog_tags_label"),
}} }}
/> />
<FormControl <FormControl variant="standard" margin="dense" sx={{ minWidth: 170, maxWidth: 300, flexGrow: 1 }}>
variant="standard"
margin="dense"
sx={{ minWidth: 170, maxWidth: 300, flexGrow: 1 }}
>
<InputLabel /> <InputLabel />
<Select <Select
label={t("publish_dialog_priority_label")} label={t("publish_dialog_priority_label")}
@ -514,11 +467,7 @@ const PublishDialog = (props) => {
}} }}
> >
{account?.phone_numbers?.map((phoneNumber, i) => ( {account?.phone_numbers?.map((phoneNumber, i) => (
<MenuItem <MenuItem key={`phoneNumberMenuItem${i}`} value={phoneNumber} aria-label={phoneNumber}>
key={`phoneNumberMenuItem${i}`}
value={phoneNumber}
aria-label={phoneNumber}
>
{t("publish_dialog_call_item", { number: phoneNumber })} {t("publish_dialog_call_item", { number: phoneNumber })}
</MenuItem> </MenuItem>
))} ))}
@ -584,13 +533,7 @@ const PublishDialog = (props) => {
/> />
</ClosableRow> </ClosableRow>
)} )}
<input <input type="file" ref={attachFileInput} onChange={handleAttachFileChanged} style={{ display: "none" }} aria-hidden={true} />
type="file"
ref={attachFileInput}
onChange={handleAttachFileChanged}
style={{ display: "none" }}
aria-hidden={true}
/>
{showAttachFile && ( {showAttachFile && (
<AttachmentBox <AttachmentBox
file={attachFile} file={attachFile}
@ -712,11 +655,7 @@ const PublishDialog = (props) => {
/> />
)} )}
{account && !account?.phone_numbers && ( {account && !account?.phone_numbers && (
<Tooltip <Tooltip title={t("publish_dialog_chip_call_no_verified_numbers_tooltip")}>
title={t(
"publish_dialog_chip_call_no_verified_numbers_tooltip"
)}
>
<span> <span>
<Chip <Chip
clickable clickable
@ -733,23 +672,13 @@ const PublishDialog = (props) => {
<Trans <Trans
i18nKey="publish_dialog_details_examples_description" i18nKey="publish_dialog_details_examples_description"
components={{ components={{
docsLink: ( docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />,
<Link
href="https://ntfy.sh/docs"
target="_blank"
rel="noopener"
/>
),
}} }}
/> />
</Typography> </Typography>
</DialogContent> </DialogContent>
<DialogFooter status={status}> <DialogFooter status={status}>
{activeRequest && ( {activeRequest && <Button onClick={() => activeRequest.abort()}>{t("publish_dialog_button_cancel_sending")}</Button>}
<Button onClick={() => activeRequest.abort()}>
{t("publish_dialog_button_cancel_sending")}
</Button>
)}
{!activeRequest && ( {!activeRequest && (
<> <>
<FormControlLabel <FormControlLabel
@ -761,16 +690,12 @@ const PublishDialog = (props) => {
checked={publishAnother} checked={publishAnother}
onChange={(ev) => setPublishAnother(ev.target.checked)} onChange={(ev) => setPublishAnother(ev.target.checked)}
inputProps={{ inputProps={{
"aria-label": t( "aria-label": t("publish_dialog_checkbox_publish_another"),
"publish_dialog_checkbox_publish_another"
),
}} }}
/> />
} }
/> />
<Button onClick={props.onClose}> <Button onClick={props.onClose}>{t("publish_dialog_button_cancel")}</Button>
{t("publish_dialog_button_cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}> <Button onClick={handleSubmit} disabled={!sendButtonEnabled}>
{t("publish_dialog_button_send")} {t("publish_dialog_button_send")}
</Button> </Button>
@ -796,12 +721,7 @@ const ClosableRow = (props) => {
<Row> <Row>
{props.children} {props.children}
{closable && ( {closable && (
<DialogIconButton <DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{ marginLeft: "6px" }} aria-label={props.closeLabel}>
disabled={props.disabled}
onClick={props.onClose}
sx={{ marginLeft: "6px" }}
aria-label={props.closeLabel}
>
<Close /> <Close />
</DialogIconButton> </DialogIconButton>
)} )}
@ -856,23 +776,14 @@ const AttachmentBox = (props) => {
<Typography variant="body2" sx={{ color: "text.primary" }}> <Typography variant="body2" sx={{ color: "text.primary" }}>
{formatBytes(file.size)} {formatBytes(file.size)}
{props.error && ( {props.error && (
<Typography <Typography component="span" sx={{ color: "error.main" }} aria-live="polite">
component="span"
sx={{ color: "error.main" }}
aria-live="polite"
>
{" "} {" "}
({props.error}) ({props.error})
</Typography> </Typography>
)} )}
</Typography> </Typography>
</Box> </Box>
<DialogIconButton <DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{ marginLeft: "6px" }} aria-label={t("publish_dialog_attached_file_remove")}>
disabled={props.disabled}
onClick={props.onClose}
sx={{ marginLeft: "6px" }}
aria-label={t("publish_dialog_attached_file_remove")}
>
<Close /> <Close />
</DialogIconButton> </DialogIconButton>
</Box> </Box>
@ -888,22 +799,14 @@ const ExpandingTextField = (props) => {
if (!boundingRect) { if (!boundingRect) {
return props.minWidth; return props.minWidth;
} }
return boundingRect.width >= props.minWidth return boundingRect.width >= props.minWidth ? Math.round(boundingRect.width) : props.minWidth;
? Math.round(boundingRect.width)
: props.minWidth;
}; };
useEffect(() => { useEffect(() => {
setTextWidth(determineTextWidth() + 5); setTextWidth(determineTextWidth() + 5);
}, [props.value]); }, [props.value]);
return ( return (
<> <>
<Typography <Typography ref={invisibleFieldRef} component="span" variant={props.variant} aria-hidden={true} sx={{ position: "absolute", left: "-200%" }}>
ref={invisibleFieldRef}
component="span"
variant={props.variant}
aria-hidden={true}
sx={{ position: "absolute", left: "-200%" }}
>
{props.value} {props.value}
</Typography> </Typography>
<TextField <TextField
@ -983,9 +886,7 @@ const DropBox = () => {
alignItems: "center", alignItems: "center",
}} }}
> >
<Typography variant="h5"> <Typography variant="h5">{t("publish_dialog_drop_file_here")}</Typography>
{t("publish_dialog_drop_file_here")}
</Typography>
</Box> </Box>
</Box> </Box>
); );

View File

@ -28,16 +28,13 @@ export const ReserveAddDialog = (props) => {
const [everyone, setEveryone] = useState(Permission.DENY_ALL); const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const allowTopicEdit = !props.topic; const allowTopicEdit = !props.topic;
const alreadyReserved = const alreadyReserved = props.reservations.filter((r) => r.topic === topic).length > 0;
props.reservations.filter((r) => r.topic === topic).length > 0;
const submitButtonEnabled = validTopic(topic) && !alreadyReserved; const submitButtonEnabled = validTopic(topic) && !alreadyReserved;
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
await accountApi.upsertReservation(topic, everyone); await accountApi.upsertReservation(topic, everyone);
console.debug( console.debug(`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`);
`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`
);
} catch (e) { } catch (e) {
console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); console.log(`[ReserveAddDialog] Error adding topic reservation.`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
@ -54,18 +51,10 @@ export const ReserveAddDialog = (props) => {
}; };
return ( return (
<Dialog <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
open={props.open}
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>{t("prefs_reservations_dialog_title_add")}</DialogTitle> <DialogTitle>{t("prefs_reservations_dialog_title_add")}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>{t("prefs_reservations_dialog_description")}</DialogContentText>
{t("prefs_reservations_dialog_description")}
</DialogContentText>
{allowTopicEdit && ( {allowTopicEdit && (
<TextField <TextField
autoFocus autoFocus
@ -80,11 +69,7 @@ export const ReserveAddDialog = (props) => {
variant="standard" variant="standard"
/> />
)} )}
<ReserveTopicSelect <ReserveTopicSelect value={everyone} onChange={setEveryone} sx={{ mt: 1 }} />
value={everyone}
onChange={setEveryone}
sx={{ mt: 1 }}
/>
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button> <Button onClick={props.onClose}>{t("common_cancel")}</Button>
@ -99,17 +84,13 @@ export const ReserveAddDialog = (props) => {
export const ReserveEditDialog = (props) => { export const ReserveEditDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [everyone, setEveryone] = useState( const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL);
props.reservation?.everyone || Permission.DENY_ALL
);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
await accountApi.upsertReservation(props.reservation.topic, everyone); await accountApi.upsertReservation(props.reservation.topic, everyone);
console.debug( console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`);
`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`
);
} catch (e) { } catch (e) {
console.log(`[ReserveEditDialog] Error updating topic reservation.`, e); console.log(`[ReserveEditDialog] Error updating topic reservation.`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
@ -123,23 +104,11 @@ export const ReserveEditDialog = (props) => {
}; };
return ( return (
<Dialog <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
open={props.open}
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>{t("prefs_reservations_dialog_title_edit")}</DialogTitle> <DialogTitle>{t("prefs_reservations_dialog_title_edit")}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>{t("prefs_reservations_dialog_description")}</DialogContentText>
{t("prefs_reservations_dialog_description")} <ReserveTopicSelect value={everyone} onChange={setEveryone} sx={{ mt: 1 }} />
</DialogContentText>
<ReserveTopicSelect
value={everyone}
onChange={setEveryone}
sx={{ mt: 1 }}
/>
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button> <Button onClick={props.onClose}>{t("common_cancel")}</Button>
@ -158,9 +127,7 @@ export const ReserveDeleteDialog = (props) => {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
await accountApi.deleteReservation(props.topic, deleteMessages); await accountApi.deleteReservation(props.topic, deleteMessages);
console.debug( console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`);
`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`
);
} catch (e) { } catch (e) {
console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e); console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
@ -174,18 +141,10 @@ export const ReserveDeleteDialog = (props) => {
}; };
return ( return (
<Dialog <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
open={props.open}
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>{t("prefs_reservations_dialog_title_delete")}</DialogTitle> <DialogTitle>{t("prefs_reservations_dialog_title_delete")}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>{t("reservation_delete_dialog_description")}</DialogContentText>
{t("reservation_delete_dialog_description")}
</DialogContentText>
<FormControl fullWidth variant="standard"> <FormControl fullWidth variant="standard">
<Select <Select
value={deleteMessages} value={deleteMessages}
@ -203,17 +162,13 @@ export const ReserveDeleteDialog = (props) => {
<ListItemIcon> <ListItemIcon>
<Check /> <Check />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText primary={t("reservation_delete_dialog_action_keep_title")} />
primary={t("reservation_delete_dialog_action_keep_title")}
/>
</MenuItem> </MenuItem>
<MenuItem value={true}> <MenuItem value={true}>
<ListItemIcon> <ListItemIcon>
<DeleteForever /> <DeleteForever />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText primary={t("reservation_delete_dialog_action_delete_title")} />
primary={t("reservation_delete_dialog_action_delete_title")}
/>
</MenuItem> </MenuItem>
</Select> </Select>
</FormControl> </FormControl>

View File

@ -4,12 +4,7 @@ import { useTranslation } from "react-i18next";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText"; import ListItemText from "@mui/material/ListItemText";
import { import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
PermissionDenyAll,
PermissionRead,
PermissionReadWrite,
PermissionWrite,
} from "./ReserveIcons";
import { Permission } from "../app/AccountApi"; import { Permission } from "../app/AccountApi";
const ReserveTopicSelect = (props) => { const ReserveTopicSelect = (props) => {
@ -34,33 +29,25 @@ const ReserveTopicSelect = (props) => {
<ListItemIcon> <ListItemIcon>
<PermissionDenyAll /> <PermissionDenyAll />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText primary={t("prefs_reservations_table_everyone_deny_all")} />
primary={t("prefs_reservations_table_everyone_deny_all")}
/>
</MenuItem> </MenuItem>
<MenuItem value={Permission.READ_ONLY}> <MenuItem value={Permission.READ_ONLY}>
<ListItemIcon> <ListItemIcon>
<PermissionRead /> <PermissionRead />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText primary={t("prefs_reservations_table_everyone_read_only")} />
primary={t("prefs_reservations_table_everyone_read_only")}
/>
</MenuItem> </MenuItem>
<MenuItem value={Permission.WRITE_ONLY}> <MenuItem value={Permission.WRITE_ONLY}>
<ListItemIcon> <ListItemIcon>
<PermissionWrite /> <PermissionWrite />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText primary={t("prefs_reservations_table_everyone_write_only")} />
primary={t("prefs_reservations_table_everyone_write_only")}
/>
</MenuItem> </MenuItem>
<MenuItem value={Permission.READ_WRITE}> <MenuItem value={Permission.READ_WRITE}>
<ListItemIcon> <ListItemIcon>
<PermissionReadWrite /> <PermissionReadWrite />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText primary={t("prefs_reservations_table_everyone_read_write")} />
primary={t("prefs_reservations_table_everyone_read_write")}
/>
</MenuItem> </MenuItem>
</Select> </Select>
</FormControl> </FormControl>

View File

@ -31,9 +31,7 @@ const Signup = () => {
try { try {
await accountApi.create(user.username, user.password); await accountApi.create(user.username, user.password);
const token = await accountApi.login(user); const token = await accountApi.login(user);
console.log( console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
`[Signup] User signup for user ${user.username} successful, token is ${token}`
);
session.store(user.username, token); session.store(user.username, token);
window.location.href = routes.app; window.location.href = routes.app;
} catch (e) { } catch (e) {
@ -51,9 +49,7 @@ const Signup = () => {
if (!config.enable_signup) { if (!config.enable_signup) {
return ( return (
<AvatarBox> <AvatarBox>
<Typography sx={{ typography: "h6" }}> <Typography sx={{ typography: "h6" }}>{t("signup_disabled")}</Typography>
{t("signup_disabled")}
</Typography>
</AvatarBox> </AvatarBox>
); );
} }
@ -61,12 +57,7 @@ const Signup = () => {
return ( return (
<AvatarBox> <AvatarBox>
<Typography sx={{ typography: "h6" }}>{t("signup_title")}</Typography> <Typography sx={{ typography: "h6" }}>{t("signup_title")}</Typography>
<Box <Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1, maxWidth: 400 }}>
component="form"
onSubmit={handleSubmit}
noValidate
sx={{ mt: 1, maxWidth: 400 }}
>
<TextField <TextField
margin="dense" margin="dense"
required required
@ -130,13 +121,7 @@ const Signup = () => {
), ),
}} }}
/> />
<Button <Button type="submit" fullWidth variant="contained" disabled={username === "" || password === "" || password !== confirm} sx={{ mt: 2, mb: 2 }}>
type="submit"
fullWidth
variant="contained"
disabled={username === "" || password === "" || password !== confirm}
sx={{ mt: 2, mb: 2 }}
>
{t("signup_form_button_submit")} {t("signup_form_button_submit")}
</Button> </Button>
{error && ( {error && (

View File

@ -6,21 +6,10 @@ import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent"; import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText"; import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle"; import DialogTitle from "@mui/material/DialogTitle";
import { import { Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery } from "@mui/material";
Autocomplete,
Checkbox,
FormControlLabel,
FormGroup,
useMediaQuery,
} from "@mui/material";
import theme from "./theme"; import theme from "./theme";
import api from "../app/Api"; import api from "../app/Api";
import { import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils";
randomAlphanumericString,
topicUrl,
validTopic,
validUrl,
} from "../app/utils";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller"; import poller from "../app/Poller";
@ -64,14 +53,7 @@ const SubscribeDialog = (props) => {
onSuccess={handleSuccess} onSuccess={handleSuccess}
/> />
)} )}
{showLoginPage && ( {showLoginPage && <LoginPage baseUrl={baseUrl} topic={topic} onBack={() => setShowLoginPage(false)} onSuccess={handleSuccess} />}
<LoginPage
baseUrl={baseUrl}
topic={topic}
onBack={() => setShowLoginPage(false)}
onSuccess={handleSuccess}
/>
)}
</Dialog> </Dialog>
); );
}; };
@ -85,37 +67,20 @@ const SubscribePage = (props) => {
const [everyone, setEveryone] = useState(Permission.DENY_ALL); const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url; const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url;
const topic = props.topic; const topic = props.topic;
const existingTopicUrls = props.subscriptions.map((s) => const existingTopicUrls = props.subscriptions.map((s) => topicUrl(s.baseUrl, s.topic));
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 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 = const reserveTopicEnabled =
session.exists() && session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0));
(account?.role === Role.ADMIN ||
(account?.role === Role.USER &&
(account?.stats.reservations_remaining || 0) > 0));
const handleSubscribe = async () => { const handleSubscribe = async () => {
const user = await userManager.get(baseUrl); // May be undefined const user = await userManager.get(baseUrl); // May be undefined
const username = user const username = user ? user.username : t("subscribe_dialog_error_user_anonymous");
? user.username
: t("subscribe_dialog_error_user_anonymous");
// Check read access to topic // Check read access to topic
const success = await api.topicAuth(baseUrl, topic, user); const success = await api.topicAuth(baseUrl, topic, user);
if (!success) { if (!success) {
console.log( console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
`[SubscribeDialog] Login to ${topicUrl(
baseUrl,
topic
)} failed for user ${username}`
);
if (user) { if (user) {
setError( setError(
t("subscribe_dialog_error_user_not_authorized", { t("subscribe_dialog_error_user_not_authorized", {
@ -130,14 +95,8 @@ const SubscribePage = (props) => {
} }
// Reserve topic (if requested) // Reserve topic (if requested)
if ( if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) {
session.exists() && console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`);
baseUrl === config.base_url &&
reserveTopicVisible
) {
console.log(
`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`
);
try { try {
await accountApi.upsertReservation(topic, everyone); await accountApi.upsertReservation(topic, everyone);
} catch (e) { } catch (e) {
@ -151,12 +110,7 @@ const SubscribePage = (props) => {
} }
} }
console.log( console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
`[SubscribeDialog] Successful login to ${topicUrl(
baseUrl,
topic
)} for user ${username}`
);
props.onSuccess(); props.onSuccess();
}; };
@ -167,14 +121,10 @@ const SubscribePage = (props) => {
const subscribeButtonEnabled = (() => { const subscribeButtonEnabled = (() => {
if (anotherServerVisible) { if (anotherServerVisible) {
const isExistingTopicUrl = existingTopicUrls.includes( const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
topicUrl(baseUrl, topic)
);
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
} else { } else {
const isExistingTopicUrl = existingTopicUrls.includes( const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic));
topicUrl(config.base_url, topic)
);
return validTopic(topic) && !isExistingTopicUrl; return validTopic(topic) && !isExistingTopicUrl;
} }
})(); })();
@ -191,9 +141,7 @@ const SubscribePage = (props) => {
<> <>
<DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle> <DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>{t("subscribe_dialog_subscribe_description")}</DialogContentText>
{t("subscribe_dialog_subscribe_description")}
</DialogContentText>
<div style={{ display: "flex", paddingBottom: "8px" }} role="row"> <div style={{ display: "flex", paddingBottom: "8px" }} role="row">
<TextField <TextField
autoFocus autoFocus
@ -241,9 +189,7 @@ const SubscribePage = (props) => {
</> </>
} }
/> />
{reserveTopicVisible && ( {reserveTopicVisible && <ReserveTopicSelect value={everyone} onChange={setEveryone} />}
<ReserveTopicSelect value={everyone} onChange={setEveryone} />
)}
</FormGroup> </FormGroup>
)} )}
{!reserveTopicVisible && ( {!reserveTopicVisible && (
@ -253,9 +199,7 @@ const SubscribePage = (props) => {
<Checkbox <Checkbox
onChange={handleUseAnotherChanged} onChange={handleUseAnotherChanged}
inputProps={{ inputProps={{
"aria-label": t( "aria-label": t("subscribe_dialog_subscribe_use_another_label"),
"subscribe_dialog_subscribe_use_another_label"
),
}} }}
/> />
} }
@ -268,12 +212,7 @@ const SubscribePage = (props) => {
inputValue={props.baseUrl} inputValue={props.baseUrl}
onInputChange={updateBaseUrl} onInputChange={updateBaseUrl}
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField {...params} placeholder={config.base_url} variant="standard" aria-label={t("subscribe_dialog_subscribe_base_url_label")} />
{...params}
placeholder={config.base_url}
variant="standard"
aria-label={t("subscribe_dialog_subscribe_base_url_label")}
/>
)} )}
/> />
)} )}
@ -281,9 +220,7 @@ const SubscribePage = (props) => {
)} )}
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onCancel}> <Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
{t("subscribe_dialog_subscribe_button_cancel")}
</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}> <Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>
{t("subscribe_dialog_subscribe_button_subscribe")} {t("subscribe_dialog_subscribe_button_subscribe")}
</Button> </Button>
@ -304,23 +241,11 @@ const LoginPage = (props) => {
const user = { baseUrl, username, password }; const user = { baseUrl, username, password };
const success = await api.topicAuth(baseUrl, topic, user); const success = await api.topicAuth(baseUrl, topic, user);
if (!success) { if (!success) {
console.log( console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
`[SubscribeDialog] Login to ${topicUrl( setError(t("subscribe_dialog_error_user_not_authorized", { username: username }));
baseUrl,
topic
)} failed for user ${username}`
);
setError(
t("subscribe_dialog_error_user_not_authorized", { username: username })
);
return; return;
} }
console.log( console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
`[SubscribeDialog] Successful login to ${topicUrl(
baseUrl,
topic
)} for user ${username}`
);
await userManager.save(user); await userManager.save(user);
props.onSuccess(); props.onSuccess();
}; };
@ -329,9 +254,7 @@ const LoginPage = (props) => {
<> <>
<DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle> <DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>{t("subscribe_dialog_login_description")}</DialogContentText>
{t("subscribe_dialog_login_description")}
</DialogContentText>
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"
@ -362,9 +285,7 @@ const LoginPage = (props) => {
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onBack}>{t("common_back")}</Button> <Button onClick={props.onBack}>{t("common_back")}</Button>
<Button onClick={handleLogin}> <Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
{t("subscribe_dialog_login_button_login")}
</Button>
</DialogFooter> </DialogFooter>
</> </>
); );

View File

@ -6,13 +6,7 @@ import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent"; import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText"; import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle"; import DialogTitle from "@mui/material/DialogTitle";
import { import { Chip, InputAdornment, Portal, Snackbar, useMediaQuery } from "@mui/material";
Chip,
InputAdornment,
Portal,
Snackbar,
useMediaQuery,
} from "@mui/material";
import theme from "./theme"; import theme from "./theme";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
@ -28,11 +22,7 @@ import { useNavigate } from "react-router-dom";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { Clear } from "@mui/icons-material"; import { Clear } from "@mui/icons-material";
import { AccountContext } from "./App"; import { AccountContext } from "./App";
import { import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
ReserveAddDialog,
ReserveDeleteDialog,
ReserveEditDialog,
} from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors"; import { UnauthorizedError } from "../app/errors";
export const SubscriptionPopup = (props) => { export const SubscriptionPopup = (props) => {
@ -48,19 +38,11 @@ export const SubscriptionPopup = (props) => {
const placement = props.placement ?? "left"; const placement = props.placement ?? "left";
const reservations = account?.reservations || []; const reservations = account?.reservations || [];
const showReservationAdd = const showReservationAdd = config.enable_reservations && !subscription?.reservation && account?.stats.reservations_remaining > 0;
config.enable_reservations &&
!subscription?.reservation &&
account?.stats.reservations_remaining > 0;
const showReservationAddDisabled = const showReservationAddDisabled =
!showReservationAdd && !showReservationAdd && config.enable_reservations && !subscription?.reservation && (config.enable_payments || account?.stats.reservations_remaining === 0);
config.enable_reservations && const showReservationEdit = config.enable_reservations && !!subscription?.reservation;
!subscription?.reservation && const showReservationDelete = config.enable_reservations && !!subscription?.reservation;
(config.enable_payments || account?.stats.reservations_remaining === 0);
const showReservationEdit =
config.enable_reservations && !!subscription?.reservation;
const showReservationDelete =
config.enable_reservations && !!subscription?.reservation;
const handleChangeDisplayName = async () => { const handleChangeDisplayName = async () => {
setDisplayNameDialogOpen(true); setDisplayNameDialogOpen(true);
@ -115,14 +97,10 @@ export const SubscriptionPopup = (props) => {
])[0]; ])[0];
const nowSeconds = Math.round(Date.now() / 1000); const nowSeconds = Math.round(Date.now() / 1000);
const message = shuffle([ const message = shuffle([
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime( `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
nowSeconds
)} right now. Is that early or late?`,
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`, `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`, `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
`Alright then, it's ${formatShortDateTime( `Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
nowSeconds
)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`, `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`, `I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`, `It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`,
@ -140,24 +118,16 @@ export const SubscriptionPopup = (props) => {
}; };
const handleClearAll = async () => { const handleClearAll = async () => {
console.log( console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`);
`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`
);
await subscriptionManager.deleteNotifications(props.subscription.id); await subscriptionManager.deleteNotifications(props.subscription.id);
}; };
const handleUnsubscribe = async () => { const handleUnsubscribe = async () => {
console.log( console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription);
`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`,
props.subscription
);
await subscriptionManager.remove(props.subscription.id); await subscriptionManager.remove(props.subscription.id);
if (session.exists() && !subscription.internal) { if (session.exists() && !subscription.internal) {
try { try {
await accountApi.deleteSubscription( await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic);
props.subscription.baseUrl,
props.subscription.topic
);
} catch (e) { } catch (e) {
console.log(`[SubscriptionPopup] Error unsubscribing`, e); console.log(`[SubscriptionPopup] Error unsubscribing`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
@ -175,67 +145,26 @@ export const SubscriptionPopup = (props) => {
return ( return (
<> <>
<PopupMenu <PopupMenu horizontal={placement} anchorEl={props.anchor} open={!!props.anchor} onClose={props.onClose}>
horizontal={placement} <MenuItem onClick={handleChangeDisplayName}>{t("action_bar_change_display_name")}</MenuItem>
anchorEl={props.anchor} {showReservationAdd && <MenuItem onClick={handleReserveAdd}>{t("action_bar_reservation_add")}</MenuItem>}
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 && ( {showReservationAddDisabled && (
<MenuItem sx={{ cursor: "default" }}> <MenuItem sx={{ cursor: "default" }}>
<span style={{ opacity: 0.3 }}> <span style={{ opacity: 0.3 }}>{t("action_bar_reservation_add")}</span>
{t("action_bar_reservation_add")}
</span>
<ReserveLimitChip /> <ReserveLimitChip />
</MenuItem> </MenuItem>
)} )}
{showReservationEdit && ( {showReservationEdit && <MenuItem onClick={handleReserveEdit}>{t("action_bar_reservation_edit")}</MenuItem>}
<MenuItem onClick={handleReserveEdit}> {showReservationDelete && <MenuItem onClick={handleReserveDelete}>{t("action_bar_reservation_delete")}</MenuItem>}
{t("action_bar_reservation_edit")} <MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
</MenuItem> <MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
)} <MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
{showReservationDelete && (
<MenuItem onClick={handleReserveDelete}>
{t("action_bar_reservation_delete")}
</MenuItem>
)}
<MenuItem onClick={handleSendTestMessage}>
{t("action_bar_send_test_notification")}
</MenuItem>
<MenuItem onClick={handleClearAll}>
{t("action_bar_clear_notifications")}
</MenuItem>
<MenuItem onClick={handleUnsubscribe}>
{t("action_bar_unsubscribe")}
</MenuItem>
</PopupMenu> </PopupMenu>
<Portal> <Portal>
<Snackbar <Snackbar open={showPublishError} autoHideDuration={3000} onClose={() => setShowPublishError(false)} message={t("message_bar_error_publishing")} />
open={showPublishError} <DisplayNameDialog open={displayNameDialogOpen} subscription={subscription} onClose={() => setDisplayNameDialogOpen(false)} />
autoHideDuration={3000}
onClose={() => setShowPublishError(false)}
message={t("message_bar_error_publishing")}
/>
<DisplayNameDialog
open={displayNameDialogOpen}
subscription={subscription}
onClose={() => setDisplayNameDialogOpen(false)}
/>
{showReservationAdd && ( {showReservationAdd && (
<ReserveAddDialog <ReserveAddDialog open={reserveAddDialogOpen} topic={subscription.topic} reservations={reservations} onClose={() => setReserveAddDialogOpen(false)} />
open={reserveAddDialogOpen}
topic={subscription.topic}
reservations={reservations}
onClose={() => setReserveAddDialogOpen(false)}
/>
)} )}
{showReservationEdit && ( {showReservationEdit && (
<ReserveEditDialog <ReserveEditDialog
@ -246,11 +175,7 @@ export const SubscriptionPopup = (props) => {
/> />
)} )}
{showReservationDelete && ( {showReservationDelete && (
<ReserveDeleteDialog <ReserveDeleteDialog open={reserveDeleteDialogOpen} topic={subscription.topic} onClose={() => setReserveDeleteDialogOpen(false)} />
open={reserveDeleteDialogOpen}
topic={subscription.topic}
onClose={() => setReserveDeleteDialogOpen(false)}
/>
)} )}
</Portal> </Portal>
</> </>
@ -261,28 +186,17 @@ const DisplayNameDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const subscription = props.subscription; const subscription = props.subscription;
const [error, setError] = useState(""); const [error, setError] = useState("");
const [displayName, setDisplayName] = useState( const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
subscription.displayName ?? ""
);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSave = async () => { const handleSave = async () => {
await subscriptionManager.setDisplayName(subscription.id, displayName); await subscriptionManager.setDisplayName(subscription.id, displayName);
if (session.exists() && !subscription.internal) { if (session.exists() && !subscription.internal) {
try { try {
console.log( console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`);
`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}` await accountApi.updateSubscription(subscription.baseUrl, subscription.topic, { display_name: displayName });
);
await accountApi.updateSubscription(
subscription.baseUrl,
subscription.topic,
{ display_name: displayName }
);
} catch (e) { } catch (e) {
console.log( console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e);
`[SubscriptionSettingsDialog] Error updating subscription`,
e
);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else { } else {
@ -295,18 +209,10 @@ const DisplayNameDialog = (props) => {
}; };
return ( return (
<Dialog <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
open={props.open}
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>{t("display_name_dialog_title")}</DialogTitle> <DialogTitle>{t("display_name_dialog_title")}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>{t("display_name_dialog_description")}</DialogContentText>
{t("display_name_dialog_description")}
</DialogContentText>
<TextField <TextField
autoFocus autoFocus
placeholder={t("display_name_dialog_placeholder")} placeholder={t("display_name_dialog_placeholder")}
@ -340,17 +246,10 @@ const DisplayNameDialog = (props) => {
export const ReserveLimitChip = () => { export const ReserveLimitChip = () => {
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
if ( if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) {
account?.role === Role.ADMIN ||
account?.stats.reservations_remaining > 0
) {
return <></>; return <></>;
} else if (config.enable_payments) { } else if (config.enable_payments) {
return account?.limits.reservations > 0 ? ( return account?.limits.reservations > 0 ? <LimitReachedChip /> : <ProChip />;
<LimitReachedChip />
) : (
<ProChip />
);
} else if (account) { } else if (account) {
return <LimitReachedChip />; return <LimitReachedChip />;
} }

View File

@ -3,16 +3,7 @@ import { useContext, useEffect, useState } from "react";
import Dialog from "@mui/material/Dialog"; import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent"; import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle"; import DialogTitle from "@mui/material/DialogTitle";
import { import { Alert, CardActionArea, CardContent, Chip, Link, ListItem, Switch, useMediaQuery } from "@mui/material";
Alert,
CardActionArea,
CardContent,
Chip,
Link,
ListItem,
Switch,
useMediaQuery,
} from "@mui/material";
import theme from "./theme"; import theme from "./theme";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import accountApi, { SubscriptionInterval } from "../app/AccountApi"; import accountApi, { SubscriptionInterval } from "../app/AccountApi";
@ -21,12 +12,7 @@ import routes from "./routes";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { AccountContext } from "./App"; import { AccountContext } from "./App";
import { import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils";
formatBytes,
formatNumber,
formatPrice,
formatShortDate,
} from "../app/utils";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import List from "@mui/material/List"; import List from "@mui/material/List";
import { Check, Close } from "@mui/icons-material"; import { Check, Close } from "@mui/icons-material";
@ -43,9 +29,7 @@ const UpgradeDialog = (props) => {
const { account } = useContext(AccountContext); // May be undefined! const { account } = useContext(AccountContext); // May be undefined!
const [error, setError] = useState(""); const [error, setError] = useState("");
const [tiers, setTiers] = useState(null); const [tiers, setTiers] = useState(null);
const [interval, setInterval] = useState( const [interval, setInterval] = useState(account?.billing?.interval || SubscriptionInterval.YEAR);
account?.billing?.interval || SubscriptionInterval.YEAR
);
const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
@ -61,9 +45,7 @@ const UpgradeDialog = (props) => {
return <></>; return <></>;
} }
const tiersMap = Object.assign( const tiersMap = Object.assign(...tiers.map((tier) => ({ [tier.code]: tier })));
...tiers.map((tier) => ({ [tier.code]: tier }))
);
const newTier = tiersMap[newTierCode]; // May be undefined const newTier = tiersMap[newTierCode]; // May be undefined
const currentTier = account?.tier; // May be undefined const currentTier = account?.tier; // May be undefined
const currentInterval = account?.billing?.interval; // May be undefined const currentInterval = account?.billing?.interval; // May be undefined
@ -75,10 +57,7 @@ const UpgradeDialog = (props) => {
submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup"); submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
submitAction = Action.REDIRECT_SIGNUP; submitAction = Action.REDIRECT_SIGNUP;
banner = null; banner = null;
} else if ( } else if (currentTierCode === newTierCode && (currentInterval === undefined || currentInterval === interval)) {
currentTierCode === newTierCode &&
(currentInterval === undefined || currentInterval === interval)
) {
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
submitAction = null; submitAction = null;
banner = currentTierCode ? Banner.PRORATION_INFO : null; banner = currentTierCode ? Banner.PRORATION_INFO : null;
@ -99,10 +78,7 @@ const UpgradeDialog = (props) => {
// Exceptional conditions // Exceptional conditions
if (loading) { if (loading) {
submitAction = null; submitAction = null;
} else if ( } else if (newTier?.code && account?.reservations?.length > newTier?.limits?.reservations) {
newTier?.code &&
account?.reservations?.length > newTier?.limits?.reservations
) {
submitAction = null; submitAction = null;
banner = Banner.RESERVATIONS_WARNING; banner = Banner.RESERVATIONS_WARNING;
} }
@ -115,10 +91,7 @@ const UpgradeDialog = (props) => {
try { try {
setLoading(true); setLoading(true);
if (submitAction === Action.CREATE_SUBSCRIPTION) { if (submitAction === Action.CREATE_SUBSCRIPTION) {
const response = await accountApi.createBillingSubscription( const response = await accountApi.createBillingSubscription(newTierCode, interval);
newTierCode,
interval
);
window.location.href = response.redirect_url; window.location.href = response.redirect_url;
} else if (submitAction === Action.UPDATE_SUBSCRIPTION) { } else if (submitAction === Action.UPDATE_SUBSCRIPTION) {
await accountApi.updateBillingSubscription(newTierCode, interval); await accountApi.updateBillingSubscription(newTierCode, interval);
@ -142,16 +115,12 @@ const UpgradeDialog = (props) => {
let discount = 0, let discount = 0,
upto = false; upto = false;
if (newTier?.prices) { if (newTier?.prices) {
discount = Math.round( discount = Math.round(((newTier.prices.month * 12) / newTier.prices.year - 1) * 100);
((newTier.prices.month * 12) / newTier.prices.year - 1) * 100
);
} else { } else {
let n = 0; let n = 0;
for (const t of tiers) { for (const t of tiers) {
if (t.prices) { if (t.prices) {
const tierDiscount = Math.round( const tierDiscount = Math.round(((t.prices.month * 12) / t.prices.year - 1) * 100);
((t.prices.month * 12) / t.prices.year - 1) * 100
);
if (tierDiscount > discount) { if (tierDiscount > discount) {
discount = tierDiscount; discount = tierDiscount;
n++; n++;
@ -162,12 +131,7 @@ const UpgradeDialog = (props) => {
} }
return ( return (
<Dialog <Dialog open={props.open} onClose={props.onCancel} maxWidth="lg" fullScreen={fullScreen}>
open={props.open}
onClose={props.onCancel}
maxWidth="lg"
fullScreen={fullScreen}
>
<DialogTitle> <DialogTitle>
<div style={{ display: "flex", flexDirection: "row" }}> <div style={{ display: "flex", flexDirection: "row" }}>
<div style={{ flexGrow: 1 }}>{t("account_upgrade_dialog_title")}</div> <div style={{ flexGrow: 1 }}>{t("account_upgrade_dialog_title")}</div>
@ -184,13 +148,7 @@ const UpgradeDialog = (props) => {
</Typography> </Typography>
<Switch <Switch
checked={interval === SubscriptionInterval.YEAR} checked={interval === SubscriptionInterval.YEAR}
onChange={(ev) => onChange={(ev) => setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)}
setInterval(
ev.target.checked
? SubscriptionInterval.YEAR
: SubscriptionInterval.MONTH
)
}
/> />
<Typography component="span" variant="subtitle1"> <Typography component="span" variant="subtitle1">
{t("account_upgrade_dialog_interval_yearly")} {t("account_upgrade_dialog_interval_yearly")}
@ -199,20 +157,12 @@ const UpgradeDialog = (props) => {
<Chip <Chip
label={ label={
upto upto
? t( ? t("account_upgrade_dialog_interval_yearly_discount_save_up_to", { discount: discount })
"account_upgrade_dialog_interval_yearly_discount_save_up_to", : t("account_upgrade_dialog_interval_yearly_discount_save", { discount: discount })
{ discount: discount }
)
: t(
"account_upgrade_dialog_interval_yearly_discount_save",
{ discount: discount }
)
} }
color="primary" color="primary"
size="small" size="small"
variant={ variant={interval === SubscriptionInterval.YEAR ? "filled" : "outlined"}
interval === SubscriptionInterval.YEAR ? "filled" : "outlined"
}
sx={{ marginLeft: "5px" }} sx={{ marginLeft: "5px" }}
/> />
)} )}
@ -258,9 +208,7 @@ const UpgradeDialog = (props) => {
<Alert severity="warning" sx={{ fontSize: "1rem" }}> <Alert severity="warning" sx={{ fontSize: "1rem" }}>
<Trans <Trans
i18nKey="account_upgrade_dialog_reservations_warning" i18nKey="account_upgrade_dialog_reservations_warning"
count={ count={account?.reservations.length - newTier?.limits.reservations}
account?.reservations.length - newTier?.limits.reservations
}
components={{ components={{
Link: <NavLink to={routes.settings} />, Link: <NavLink to={routes.settings} />,
}} }}
@ -309,9 +257,7 @@ const UpgradeDialog = (props) => {
{error} {error}
</DialogContentText> </DialogContentText>
<DialogActions sx={{ paddingRight: 2 }}> <DialogActions sx={{ paddingRight: 2 }}>
<Button onClick={props.onCancel}> <Button onClick={props.onCancel}>{t("account_upgrade_dialog_button_cancel")}</Button>
{t("account_upgrade_dialog_button_cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!submitAction}> <Button onClick={handleSubmit} disabled={!submitAction}>
{submitButtonLabel} {submitButtonLabel}
</Button> </Button>
@ -382,16 +328,10 @@ const TierCard = (props) => {
{tier.name || t("account_basics_tier_free")} {tier.name || t("account_basics_tier_free")}
</Typography> </Typography>
<div> <div>
<Typography <Typography component="span" variant="h4" sx={{ fontWeight: 500, marginRight: "3px" }}>
component="span"
variant="h4"
sx={{ fontWeight: 500, marginRight: "3px" }}
>
{formatPrice(monthlyPrice)} {formatPrice(monthlyPrice)}
</Typography> </Typography>
{monthlyPrice > 0 && ( {monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}</>}
<>/ {t("account_upgrade_dialog_tier_price_per_month")}</>
)}
</div> </div>
<List dense> <List dense>
{tier.limits.reservations > 0 && ( {tier.limits.reservations > 0 && (
@ -423,21 +363,10 @@ const TierCard = (props) => {
</Feature> </Feature>
)} )}
<Feature> <Feature>
{t( {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}
"account_upgrade_dialog_tier_features_attachment_file_size",
{ filesize: formatBytes(tier.limits.attachment_file_size, 0) }
)}
</Feature> </Feature>
{tier.limits.reservations === 0 && ( {tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
<NoFeature> {tier.limits.calls === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_calls")}</NoFeature>}
{t("account_upgrade_dialog_tier_features_no_reservations")}
</NoFeature>
)}
{tier.limits.calls === 0 && (
<NoFeature>
{t("account_upgrade_dialog_tier_features_no_calls")}
</NoFeature>
)}
</List> </List>
{tier.prices && props.interval === SubscriptionInterval.MONTH && ( {tier.prices && props.interval === SubscriptionInterval.MONTH && (
<Typography variant="body2" color="gray"> <Typography variant="body2" color="gray">
@ -476,10 +405,7 @@ const FeatureItem = (props) => {
{props.feature && <Check fontSize="small" sx={{ color: "#338574" }} />} {props.feature && <Check fontSize="small" sx={{ color: "#338574" }} />}
{!props.feature && <Close fontSize="small" sx={{ color: "gray" }} />} {!props.feature && <Close fontSize="small" sx={{ color: "gray" }} />}
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText sx={{ mt: "2px", mb: "2px" }} primary={<Typography variant="body1">{props.children}</Typography>} />
sx={{ mt: "2px", mb: "2px" }}
primary={<Typography variant="body1">{props.children}</Typography>}
/>
</ListItem> </ListItem>
); );
}; };

View File

@ -32,41 +32,25 @@ export const useConnectionListeners = (account, subscriptions, users) => {
}; };
const handleInternalMessage = async (message) => { const handleInternalMessage = async (message) => {
console.log( console.log(`[ConnectionListener] Received message on sync topic`, message.message);
`[ConnectionListener] Received message on sync topic`,
message.message
);
try { try {
const data = JSON.parse(message.message); const data = JSON.parse(message.message);
if (data.event === "sync") { if (data.event === "sync") {
console.log(`[ConnectionListener] Triggering account sync`); console.log(`[ConnectionListener] Triggering account sync`);
await accountApi.sync(); await accountApi.sync();
} else { } else {
console.log( console.log(`[ConnectionListener] Unknown message type. Doing nothing.`);
`[ConnectionListener] Unknown message type. Doing nothing.`
);
} }
} catch (e) { } catch (e) {
console.log( console.log(`[ConnectionListener] Error parsing sync topic message`, e);
`[ConnectionListener] Error parsing sync topic message`,
e
);
} }
}; };
const handleNotification = async (subscriptionId, notification) => { const handleNotification = async (subscriptionId, notification) => {
const added = await subscriptionManager.addNotification( const added = await subscriptionManager.addNotification(subscriptionId, notification);
subscriptionId,
notification
);
if (added) { if (added) {
const defaultClickAction = (subscription) => const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription));
navigate(routes.forSubscription(subscription)); await notifier.notify(subscriptionId, notification, defaultClickAction);
await notifier.notify(
subscriptionId,
notification,
defaultClickAction
);
} }
}; };
connectionManager.registerStateListener(subscriptionManager.updateState); connectionManager.registerStateListener(subscriptionManager.updateState);
@ -109,20 +93,12 @@ export const useAutoSubscribe = (subscriptions, selected) => {
return; return;
} }
setHasRun(true); setHasRun(true);
const eligible = const eligible = params.topic && !selected && !disallowedTopic(params.topic);
params.topic && !selected && !disallowedTopic(params.topic);
if (eligible) { if (eligible) {
const baseUrl = params.baseUrl const baseUrl = params.baseUrl ? expandSecureUrl(params.baseUrl) : config.base_url;
? expandSecureUrl(params.baseUrl) console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
: config.base_url;
console.log(
`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`
);
(async () => { (async () => {
const subscription = await subscriptionManager.add( const subscription = await subscriptionManager.add(baseUrl, params.topic);
baseUrl,
params.topic
);
if (session.exists()) { if (session.exists()) {
try { try {
await accountApi.addSubscription(baseUrl, params.topic); await accountApi.addSubscription(baseUrl, params.topic);