forked from mirrors/ntfy
1
0
Fork 0

Line width

This commit is contained in:
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/
public/static/langs/
public/static/langs/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,4 @@
import {
basicAuth,
bearerAuth,
encodeBase64Url,
topicShortUrl,
topicUrlWs,
} from "./utils";
import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils";
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.
*/
class Connection {
constructor(
connectionId,
subscriptionId,
baseUrl,
topic,
user,
since,
onNotification,
onStateChanged
) {
constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) {
this.connectionId = connectionId;
this.subscriptionId = subscriptionId;
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.
const wsUrl = this.wsUrl();
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`
);
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`);
this.ws = new WebSocket(wsUrl);
this.ws.onopen = (event) => {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`,
event
);
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event);
this.retryCount = 0;
this.onStateChanged(this.subscriptionId, ConnectionState.Connected);
};
this.ws.onmessage = (event) => {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`
);
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
try {
const data = JSON.parse(event.data);
if (data.event === "open") {
return;
}
const relevantAndValid =
data.event === "message" &&
"id" in data &&
"time" in data &&
"message" in data;
const relevantAndValid = data.event === "message" && "id" in data && "time" in data && "message" in data;
if (!relevantAndValid) {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`
);
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
return;
}
this.since = data.id;
this.onNotification(this.subscriptionId, data);
} catch (e) {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`
);
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`);
}
};
this.ws.onclose = (event) => {
if (event.wasClean) {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`
);
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
this.ws = null;
} else {
const retrySeconds =
retryBackoffSeconds[
Math.min(this.retryCount, retryBackoffSeconds.length - 1)
];
const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length - 1)];
this.retryCount++;
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`
);
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`);
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
}
};
this.ws.onerror = (event) => {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`,
event
);
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event);
};
}
close() {
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`
);
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`);
const socket = this.ws;
const retryTimeout = this.retryTimeout;
if (socket !== null) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,37 +10,23 @@ import config from "./config";
import { Base64 } from "js-base64";
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
export const topicUrlWs = (baseUrl, topic) =>
`${topicUrl(baseUrl, topic)}/ws`
.replaceAll("https://", "wss://")
.replaceAll("http://", "ws://");
export const topicUrlJson = (baseUrl, topic) =>
`${topicUrl(baseUrl, topic)}/json`;
export const topicUrlJsonPoll = (baseUrl, topic) =>
`${topicUrlJson(baseUrl, topic)}?poll=1`;
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) =>
`${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
export const topicUrlAuth = (baseUrl, topic) =>
`${topicUrl(baseUrl, topic)}/auth`;
export const topicShortUrl = (baseUrl, topic) =>
shortUrl(topicUrl(baseUrl, topic));
export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://");
export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
export const accountSubscriptionUrl = (baseUrl) =>
`${baseUrl}/v1/account/subscription`;
export const accountReservationUrl = (baseUrl) =>
`${baseUrl}/v1/account/reservation`;
export const accountReservationSingleUrl = (baseUrl, topic) =>
`${baseUrl}/v1/account/reservation/${topic}`;
export const accountBillingSubscriptionUrl = (baseUrl) =>
`${baseUrl}/v1/account/billing/subscription`;
export const accountBillingPortalUrl = (baseUrl) =>
`${baseUrl}/v1/account/billing/portal`;
export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`;
export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reservation`;
export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`;
export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
export const accountPhoneVerifyUrl = (baseUrl) =>
`${baseUrl}/v1/account/phone/verify`;
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
@ -208,9 +194,7 @@ export const formatShortDateTime = (timestamp) => {
};
export const formatShortDate = (timestamp) => {
return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(
new Date(timestamp * 1000)
);
return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000));
};
export const formatBytes = (bytes, decimals = 2) => {
@ -312,8 +296,7 @@ export async function* fetchLinesIterator(fileURL, headers) {
}
export const randomAlphanumericString = (len) => {
const alphabet =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let id = "";
for (let i = 0; i < len; i++) {
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 routes from "./routes";
import IconButton from "@mui/material/IconButton";
import {
formatBytes,
formatShortDate,
formatShortDateTime,
openUrl,
} from "../app/utils";
import accountApi, {
LimitBasis,
Role,
SubscriptionInterval,
SubscriptionStatus,
} from "../app/AccountApi";
import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { Pref, PrefGroup } from "./Pref";
import db from "../app/db";
@ -108,11 +98,7 @@ const Username = () => {
const labelId = "prefUsername";
return (
<Pref
labelId={labelId}
title={t("account_basics_username_title")}
description={t("account_basics_username_description")}
>
<Pref labelId={labelId} title={t("account_basics_username_title")} description={t("account_basics_username_description")}>
<div aria-labelledby={labelId}>
{session.username()}
{account?.role === Role.ADMIN ? (
@ -146,30 +132,16 @@ const ChangePassword = () => {
};
return (
<Pref
labelId={labelId}
title={t("account_basics_password_title")}
description={t("account_basics_password_description")}
>
<Pref labelId={labelId} title={t("account_basics_password_title")} description={t("account_basics_password_description")}>
<div aria-labelledby={labelId}>
<Typography
color="gray"
sx={{ float: "left", fontSize: "0.7rem", lineHeight: "3.5" }}
>
<Typography color="gray" sx={{ float: "left", fontSize: "0.7rem", lineHeight: "3.5" }}>
</Typography>
<IconButton
onClick={handleDialogOpen}
aria-label={t("account_basics_password_description")}
>
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
<EditIcon />
</IconButton>
</div>
<ChangePasswordDialog
key={`changePasswordDialog${dialogKey}`}
open={dialogOpen}
onClose={handleDialogClose}
/>
<ChangePasswordDialog key={`changePasswordDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
</Pref>
);
};
@ -190,9 +162,7 @@ const ChangePasswordDialog = (props) => {
} catch (e) {
console.log(`[Account] Error changing password`, e);
if (e instanceof IncorrectPasswordError) {
setError(
t("account_basics_password_dialog_current_password_incorrect")
);
setError(t("account_basics_password_dialog_current_password_incorrect"));
} else if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
@ -209,9 +179,7 @@ const ChangePasswordDialog = (props) => {
margin="dense"
id="current-password"
label={t("account_basics_password_dialog_current_password_label")}
aria-label={t(
"account_basics_password_dialog_current_password_label"
)}
aria-label={t("account_basics_password_dialog_current_password_label")}
type="password"
value={currentPassword}
onChange={(ev) => setCurrentPassword(ev.target.value)}
@ -233,9 +201,7 @@ const ChangePasswordDialog = (props) => {
margin="dense"
id="confirm"
label={t("account_basics_password_dialog_confirm_password_label")}
aria-label={t(
"account_basics_password_dialog_confirm_password_label"
)}
aria-label={t("account_basics_password_dialog_confirm_password_label")}
type="password"
value={confirmPassword}
onChange={(ev) => setConfirmPassword(ev.target.value)}
@ -245,14 +211,7 @@ const ChangePasswordDialog = (props) => {
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button
onClick={handleDialogSubmit}
disabled={
newPassword.length === 0 ||
currentPassword.length === 0 ||
newPassword !== confirmPassword
}
>
<Button onClick={handleDialogSubmit} disabled={newPassword.length === 0 || currentPassword.length === 0 || newPassword !== confirmPassword}>
{t("account_basics_password_dialog_button_submit")}
</Button>
</DialogFooter>
@ -299,9 +258,7 @@ const AccountType = () => {
: t("account_basics_tier_admin_suffix_no_tier");
accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`;
} else if (!account.tier) {
accountType = config.enable_payments
? t("account_basics_tier_free")
: t("account_basics_tier_basic");
accountType = config.enable_payments ? t("account_basics_tier_free") : t("account_basics_tier_basic");
} else {
accountType = account.tier.name;
if (account.billing?.interval === SubscriptionInterval.MONTH) {
@ -313,10 +270,7 @@ const AccountType = () => {
return (
<Pref
alignTop={
account.billing?.status === SubscriptionStatus.PAST_DUE ||
account.billing?.cancel_at > 0
}
alignTop={account.billing?.status === SubscriptionStatus.PAST_DUE || account.billing?.cancel_at > 0}
title={t("account_basics_tier_title")}
description={t("account_basics_tier_description")}
>
@ -333,49 +287,23 @@ const AccountType = () => {
</span>
</Tooltip>
)}
{config.enable_payments &&
account.role === Role.USER &&
!account.billing?.subscription && (
<Button
variant="outlined"
size="small"
startIcon={<CelebrationIcon sx={{ color: "#55b86e" }} />}
onClick={handleUpgradeClick}
sx={{ ml: 1 }}
>
{t("account_basics_tier_upgrade_button")}
</Button>
)}
{config.enable_payments &&
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 && account.role === Role.USER && !account.billing?.subscription && (
<Button variant="outlined" size="small" startIcon={<CelebrationIcon sx={{ color: "#55b86e" }} />} onClick={handleUpgradeClick} sx={{ ml: 1 }}>
{t("account_basics_tier_upgrade_button")}
</Button>
)}
{config.enable_payments && 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 && (
<UpgradeDialog
key={`upgradeDialogFromAccount${upgradeDialogKey}`}
open={upgradeDialogOpen}
onCancel={() => setUpgradeDialogOpen(false)}
/>
<UpgradeDialog key={`upgradeDialogFromAccount${upgradeDialogKey}`} open={upgradeDialogOpen} onCancel={() => setUpgradeDialogOpen(false)} />
)}
</div>
{account.billing?.status === SubscriptionStatus.PAST_DUE && (
@ -456,11 +384,7 @@ const PhoneNumbers = () => {
}
return (
<Pref
labelId={labelId}
title={t("account_basics_phone_numbers_title")}
description={t("account_basics_phone_numbers_description")}
>
<Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
<div aria-labelledby={labelId}>
{account?.phone_numbers?.map((phoneNumber) => (
<Chip
@ -474,18 +398,12 @@ const PhoneNumbers = () => {
onDelete={() => handleDelete(phoneNumber)}
/>
))}
{!account?.phone_numbers && (
<em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>
)}
{!account?.phone_numbers && <em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>}
<IconButton onClick={handleDialogOpen}>
<AddIcon />
</IconButton>
</div>
<AddPhoneNumberDialog
key={`addPhoneNumberDialog${dialogKey}`}
open={dialogOpen}
onClose={handleDialogClose}
/>
<AddPhoneNumberDialog key={`addPhoneNumberDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
<Portal>
<Snackbar
open={snackOpen}
@ -561,22 +479,16 @@ const AddPhoneNumberDialog = (props) => {
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>
{t("account_basics_phone_numbers_dialog_title")}
</DialogTitle>
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("account_basics_phone_numbers_dialog_description")}
</DialogContentText>
<DialogContentText>{t("account_basics_phone_numbers_dialog_description")}</DialogContentText>
{!verificationCodeSent && (
<div style={{ display: "flex" }}>
<TextField
margin="dense"
label={t("account_basics_phone_numbers_dialog_number_label")}
aria-label={t("account_basics_phone_numbers_dialog_number_label")}
placeholder={t(
"account_basics_phone_numbers_dialog_number_placeholder"
)}
placeholder={t("account_basics_phone_numbers_dialog_number_placeholder")}
type="tel"
value={phoneNumber}
onChange={(ev) => setPhoneNumber(ev.target.value)}
@ -585,28 +497,15 @@ const AddPhoneNumberDialog = (props) => {
sx={{ flexGrow: 1 }}
/>
<FormControl sx={{ flexWrap: "nowrap" }}>
<RadioGroup
row
sx={{ flexGrow: 1, marginTop: "8px", marginLeft: "5px" }}
>
<RadioGroup row sx={{ flexGrow: 1, marginTop: "8px", marginLeft: "5px" }}>
<FormControlLabel
value="sms"
control={
<Radio
checked={channel === "sms"}
onChange={(e) => setChannel(e.target.value)}
/>
}
control={<Radio checked={channel === "sms"} onChange={(e) => setChannel(e.target.value)} />}
label={t("account_basics_phone_numbers_dialog_channel_sms")}
/>
<FormControlLabel
value="call"
control={
<Radio
checked={channel === "call"}
onChange={(e) => setChannel(e.target.value)}
/>
}
control={<Radio checked={channel === "call"} onChange={(e) => setChannel(e.target.value)} />}
label={t("account_basics_phone_numbers_dialog_channel_call")}
sx={{ marginRight: 0 }}
/>
@ -619,9 +518,7 @@ const AddPhoneNumberDialog = (props) => {
margin="dense"
label={t("account_basics_phone_numbers_dialog_code_label")}
aria-label={t("account_basics_phone_numbers_dialog_code_label")}
placeholder={t(
"account_basics_phone_numbers_dialog_code_placeholder"
)}
placeholder={t("account_basics_phone_numbers_dialog_code_placeholder")}
type="text"
value={code}
onChange={(ev) => setCode(ev.target.value)}
@ -632,21 +529,11 @@ const AddPhoneNumberDialog = (props) => {
)}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={handleCancel}>
{verificationCodeSent ? t("common_back") : t("common_cancel")}
</Button>
<Button
onClick={handleDialogSubmit}
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 onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
<Button onClick={handleDialogSubmit} 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>
</DialogFooter>
</Dialog>
@ -687,14 +574,7 @@ const Stats = () => {
</div>
<LinearProgress
variant="determinate"
value={
account.role === Role.USER && account.limits.reservations > 0
? normalize(
account.stats.reservations,
account.limits.reservations
)
: 100
}
value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
/>
</Pref>
)}
@ -722,14 +602,7 @@ const Stats = () => {
: t("account_usage_unlimited")}
</Typography>
</div>
<LinearProgress
variant="determinate"
value={
account.role === Role.USER
? normalize(account.stats.messages, account.limits.messages)
: 100
}
/>
<LinearProgress variant="determinate" value={account.role === Role.USER ? normalize(account.stats.messages, account.limits.messages) : 100} />
</Pref>
{config.enable_emails && (
<Pref
@ -756,64 +629,49 @@ const Stats = () => {
: t("account_usage_unlimited")}
</Typography>
</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
variant="determinate"
value={
account.role === Role.USER
? normalize(account.stats.emails, account.limits.emails)
: 100
}
value={account.role === Role.USER && account.limits.calls > 0 ? normalize(account.stats.calls, account.limits.calls) : 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
variant="determinate"
value={
account.role === Role.USER && account.limits.calls > 0
? normalize(account.stats.calls, account.limits.calls)
: 100
}
/>
</Pref>
)}
<Pref
alignTop
title={t("account_usage_attachment_storage_title")}
description={t("account_usage_attachment_storage_description", {
filesize: formatBytes(account.limits.attachment_file_size),
expiry: humanizeDuration(
account.limits.attachment_expiry_duration * 1000,
{
language: i18n.resolvedLanguage,
fallbacks: ["en"],
}
),
expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, {
language: i18n.resolvedLanguage,
fallbacks: ["en"],
}),
})}
>
<div>
@ -830,49 +688,36 @@ const Stats = () => {
</div>
<LinearProgress
variant="determinate"
value={
account.role === Role.USER
? normalize(
account.stats.attachment_total_size,
account.limits.attachment_total_size
)
: 100
}
value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
/>
</Pref>
{config.enable_reservations &&
account.role === Role.USER &&
account.limits.reservations === 0 && (
<Pref
title={
<>
{t("account_usage_reservations_title")}
{config.enable_payments && <ProChip />}
</>
}
>
<em>{t("account_usage_reservations_none")}</em>
</Pref>
)}
{config.enable_calls &&
account.role === Role.USER &&
account.limits.calls === 0 && (
<Pref
title={
<>
{t("account_usage_calls_title")}
{config.enable_payments && <ProChip />}
</>
}
>
<em>{t("account_usage_calls_none")}</em>
</Pref>
)}
{config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && (
<Pref
title={
<>
{t("account_usage_reservations_title")}
{config.enable_payments && <ProChip />}
</>
}
>
<em>{t("account_usage_reservations_none")}</em>
</Pref>
)}
{config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && (
<Pref
title={
<>
{t("account_usage_calls_title")}
{config.enable_payments && <ProChip />}
</>
}
>
<em>{t("account_usage_calls_none")}</em>
</Pref>
)}
</PrefGroup>
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && (
<Typography variant="body1">
{t("account_usage_basis_ip_description")}
</Typography>
<Typography variant="body1">{t("account_usage_basis_ip_description")}</Typography>
)}
</Card>
);
@ -928,15 +773,9 @@ const Tokens = () => {
{tokens?.length > 0 && <TokensTable tokens={tokens} />}
</CardContent>
<CardActions>
<Button onClick={handleCreateClick}>
{t("account_tokens_table_create_token_button")}
</Button>
<Button onClick={handleCreateClick}>{t("account_tokens_table_create_token_button")}</Button>
</CardActions>
<TokenDialog
key={`tokenDialogCreate${dialogKey}`}
open={dialogOpen}
onClose={handleDialogClose}
/>
<TokenDialog key={`tokenDialogCreate${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
</Card>
);
};
@ -984,9 +823,7 @@ const TokensTable = (props) => {
<Table size="small" aria-label={t("account_tokens_title")}>
<TableHead>
<TableRow>
<TableCell sx={{ paddingLeft: 0 }}>
{t("account_tokens_table_token_header")}
</TableCell>
<TableCell sx={{ paddingLeft: 0 }}>{t("account_tokens_table_token_header")}</TableCell>
<TableCell>{t("account_tokens_table_label_header")}</TableCell>
<TableCell>{t("account_tokens_table_expires_header")}</TableCell>
<TableCell>{t("account_tokens_table_last_access_header")}</TableCell>
@ -995,25 +832,12 @@ const TokensTable = (props) => {
</TableHead>
<TableBody>
{tokens.map((token) => (
<TableRow
key={token.token}
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")}
>
<TableRow key={token.token} 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 style={{ fontFamily: "Monospace", fontSize: "0.9rem" }}>
{token.token.slice(0, 12)}
</span>
<span style={{ fontFamily: "Monospace", fontSize: "0.9rem" }}>{token.token.slice(0, 12)}</span>
...
<Tooltip
title={t("common_copy_to_clipboard")}
placement="right"
>
<Tooltip title={t("common_copy_to_clipboard")} placement="right">
<IconButton onClick={() => handleCopy(token.token)}>
<ContentCopy />
</IconButton>
@ -1021,25 +845,13 @@ const TokensTable = (props) => {
</span>
</TableCell>
<TableCell aria-label={t("account_tokens_table_label_header")}>
{token.token === session.token() && (
<em>{t("account_tokens_table_current_session")}</em>
)}
{token.token === session.token() && <em>{t("account_tokens_table_current_session")}</em>}
{token.token !== session.token() && (token.label || "-")}
</TableCell>
<TableCell
sx={{ whiteSpace: "nowrap" }}
aria-label={t("account_tokens_table_expires_header")}
>
{token.expires ? (
formatShortDateTime(token.expires)
) : (
<em>{t("account_tokens_table_never_expires")}</em>
)}
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
{token.expires ? formatShortDateTime(token.expires) : <em>{t("account_tokens_table_never_expires")}</em>}
</TableCell>
<TableCell
sx={{ whiteSpace: "nowrap" }}
aria-label={t("account_tokens_table_last_access_header")}
>
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
<div style={{ display: "flex", alignItems: "center" }}>
<span>{formatShortDateTime(token.last_access)}</span>
<Tooltip
@ -1047,13 +859,7 @@ const TokensTable = (props) => {
ip: token.last_origin,
})}
>
<IconButton
onClick={() =>
openUrl(
`https://whatismyipaddress.com/ip/${token.last_origin}`
)
}
>
<IconButton onClick={() => openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}>
<Public />
</IconButton>
</Tooltip>
@ -1062,24 +868,16 @@ const TokensTable = (props) => {
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{token.token !== session.token() && (
<>
<IconButton
onClick={() => handleEditClick(token)}
aria-label={t("account_tokens_dialog_title_edit")}
>
<IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
<EditIcon />
</IconButton>
<IconButton
onClick={() => handleDeleteClick(token)}
aria-label={t("account_tokens_dialog_title_delete")}
>
<IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
<CloseIcon />
</IconButton>
</>
)}
{token.token === session.token() && (
<Tooltip
title={t("account_tokens_table_cannot_delete_or_edit")}
>
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit")}>
<span>
<IconButton disabled>
<EditIcon />
@ -1095,24 +893,10 @@ const TokensTable = (props) => {
))}
</TableBody>
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("account_tokens_table_copied_to_clipboard")}
/>
<Snackbar open={snackOpen} autoHideDuration={3000} onClose={() => setSnackOpen(false)} message={t("account_tokens_table_copied_to_clipboard")} />
</Portal>
<TokenDialog
key={`tokenDialogEdit${upsertDialogKey}`}
open={upsertDialogOpen}
token={selectedToken}
onClose={handleDialogClose}
/>
<TokenDeleteDialog
open={deleteDialogOpen}
token={selectedToken}
onClose={handleDialogClose}
/>
<TokenDialog key={`tokenDialogEdit${upsertDialogKey}`} open={upsertDialogOpen} token={selectedToken} onClose={handleDialogClose} />
<TokenDeleteDialog open={deleteDialogOpen} token={selectedToken} onClose={handleDialogClose} />
</Table>
);
};
@ -1144,18 +928,8 @@ const TokenDialog = (props) => {
};
return (
<Dialog
open={props.open}
onClose={props.onClose}
maxWidth="sm"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>
{editMode
? t("account_tokens_dialog_title_edit")
: t("account_tokens_dialog_title_create")}
</DialogTitle>
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{editMode ? t("account_tokens_dialog_title_edit") : t("account_tokens_dialog_title_create")}</DialogTitle>
<DialogContent>
<TextField
margin="dense"
@ -1169,52 +943,22 @@ const TokenDialog = (props) => {
variant="standard"
/>
<FormControl fullWidth variant="standard" sx={{ mt: 1 }}>
<Select
value={expires}
onChange={(ev) => setExpires(ev.target.value)}
aria-label={t("account_tokens_dialog_expires_label")}
>
{editMode && (
<MenuItem value={-1}>
{t("account_tokens_dialog_expires_unchanged")}
</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 value={expires} onChange={(ev) => setExpires(ev.target.value)} aria-label={t("account_tokens_dialog_expires_label")}>
{editMode && <MenuItem value={-1}>{t("account_tokens_dialog_expires_unchanged")}</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>
</FormControl>
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>
{t("account_tokens_dialog_button_cancel")}
</Button>
<Button onClick={handleSubmit}>
{editMode
? t("account_tokens_dialog_button_update")
: t("account_tokens_dialog_button_create")}
</Button>
<Button onClick={props.onClose}>{t("account_tokens_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit}>{editMode ? t("account_tokens_dialog_button_update") : t("account_tokens_dialog_button_create")}</Button>
</DialogFooter>
</Dialog>
);
@ -1285,26 +1029,13 @@ const DeleteAccount = () => {
};
return (
<Pref
title={t("account_delete_title")}
description={t("account_delete_description")}
>
<Pref title={t("account_delete_title")} description={t("account_delete_description")}>
<div>
<Button
fullWidth={false}
variant="outlined"
color="error"
startIcon={<DeleteOutlineIcon />}
onClick={handleDialogOpen}
>
<Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>
{t("account_delete_title")}
</Button>
</div>
<DeleteAccountDialog
key={`deleteAccountDialog${dialogKey}`}
open={dialogOpen}
onClose={handleDialogClose}
/>
<DeleteAccountDialog key={`deleteAccountDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
</Pref>
);
};
@ -1325,9 +1056,7 @@ const DeleteAccountDialog = (props) => {
} catch (e) {
console.log(`[Account] Error deleting account`, e);
if (e instanceof IncorrectPasswordError) {
setError(
t("account_basics_password_dialog_current_password_incorrect")
);
setError(t("account_basics_password_dialog_current_password_incorrect"));
} else if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
@ -1340,9 +1069,7 @@ const DeleteAccountDialog = (props) => {
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("account_delete_title")}</DialogTitle>
<DialogContent>
<Typography variant="body1">
{t("account_delete_dialog_description")}
</Typography>
<Typography variant="body1">{t("account_delete_dialog_description")}</Typography>
<TextField
margin="dense"
id="account-delete-confirm"
@ -1361,14 +1088,8 @@ const DeleteAccountDialog = (props) => {
)}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>
{t("account_delete_dialog_button_cancel")}
</Button>
<Button
onClick={handleSubmit}
color="error"
disabled={password.length === 0}
>
<Button onClick={props.onClose}>{t("account_delete_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} color="error" disabled={password.length === 0}>
{t("account_delete_dialog_button_submit")}
</Button>
</DialogFooter>

View File

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

View File

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

View File

@ -16,11 +16,7 @@ const AvatarBox = (props) => {
height: "100vh",
}}
>
<Avatar
sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }}
src={logo}
variant="rounded"
/>
<Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />
{props.children}
</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.
const emojisByCategory = {};
const isDesktopChrome =
/Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);
const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);
const maxSupportedVersionForDesktopChrome = 11;
rawEmojis.forEach((emoji) => {
if (!emojisByCategory[emoji.category]) {
@ -26,12 +25,9 @@ rawEmojis.forEach((emoji) => {
}
try {
const unicodeVersion = parseFloat(emoji.unicode_version);
const supportedEmoji =
unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
if (supportedEmoji) {
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(
" "
)} ${emoji.tags.join(" ")}`;
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`;
const emojiWithSearchBase = { ...emoji, searchBase: searchBase };
emojisByCategory[emoji.category].push(emojiWithSearchBase);
}
@ -53,13 +49,7 @@ const EmojiPicker = (props) => {
};
return (
<Popper
open={open}
anchorEl={props.anchorEl}
placement="bottom-start"
sx={{ zIndex: 10005 }}
transition
>
<Popper open={open} anchorEl={props.anchorEl} placement="bottom-start" sx={{ zIndex: 10005 }} transition>
{({ TransitionProps }) => (
<ClickAwayListener onClickAway={props.onClose}>
<Fade {...TransitionProps} timeout={350}>
@ -92,16 +82,8 @@ const EmojiPicker = (props) => {
}}
InputProps={{
endAdornment: (
<InputAdornment
position="end"
sx={{ display: search ? "" : "none" }}
>
<IconButton
size="small"
onClick={handleSearchClear}
edge="end"
aria-label={t("emoji_picker_search_clear")}
>
<InputAdornment position="end" sx={{ display: search ? "" : "none" }}>
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}>
<Close />
</IconButton>
</InputAdornment>
@ -117,13 +99,7 @@ const EmojiPicker = (props) => {
}}
>
{Object.keys(emojisByCategory).map((category) => (
<Category
key={category}
title={category}
emojis={emojisByCategory[category]}
search={searchFields}
onPick={props.onEmojiPick}
/>
<Category key={category} title={category} emojis={emojisByCategory[category]} search={searchFields} onPick={props.onEmojiPick} />
))}
</Box>
</Box>
@ -144,12 +120,7 @@ const Category = (props) => {
</Typography>
)}
{props.emojis.map((emoji) => (
<Emoji
key={emoji.aliases[0]}
emoji={emoji}
search={props.search}
onClick={() => props.onPick(emoji.aliases[0])}
/>
<Emoji 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 title = `${emoji.description} (${emoji.aliases[0]})`;
return (
<EmojiDiv
onClick={props.onClick}
title={title}
aria-label={title}
style={{ display: matches ? "" : "none" }}
>
<EmojiDiv onClick={props.onClick} title={title} aria-label={title} style={{ display: matches ? "" : "none" }}>
{props.emoji.emoji}
</EmojiDiv>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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