Run prettier

pull/746/head
nimbleghost 2023-05-23 21:13:01 +02:00
parent 206ea312bf
commit 6f6a2d1f69
49 changed files with 22902 additions and 6633 deletions

View File

@ -15,5 +15,15 @@ var config = {
enable_emails: true,
enable_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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,10 @@
import {basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils";
import {
basicAuth,
bearerAuth,
encodeBase64Url,
topicShortUrl,
topicUrlWs,
} from "./utils";
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
@ -9,7 +15,16 @@ 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;
@ -29,55 +44,78 @@ 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') {
if (data.event === "open") {
return;
}
const relevantAndValid =
data.event === 'message' &&
'id' in data &&
'time' in data &&
'message' in data;
data.event === "message" &&
"id" in data &&
"time" in data &&
"message" in data;
if (!relevantAndValid) {
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) {
@ -99,7 +137,7 @@ class Connection {
params.push(`auth=${this.authParam()}`);
}
const wsUrl = topicUrlWs(this.baseUrl, this.topic);
return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`;
return params.length === 0 ? wsUrl : `${wsUrl}?${params.join("&")}`;
}
authParam() {

View File

@ -42,20 +42,25 @@ class ConnectionManager {
return;
}
console.log(`[ConnectionManager] Refreshing connections`);
const subscriptionsWithUsersAndConnectionId = await Promise.all(subscriptions
.map(async s => {
const [user] = users.filter(u => u.baseUrl === s.baseUrl);
const subscriptionsWithUsersAndConnectionId = await Promise.all(
subscriptions.map(async (s) => {
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
const connectionId = await makeConnectionId(s, user);
return { ...s, user, connectionId };
}));
const targetIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId);
const deletedIds = Array.from(this.connections.keys()).filter(id => !targetIds.includes(id));
})
);
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 => {
subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
const subscriptionId = subscription.id;
const connectionId = subscription.connectionId;
const added = !this.connections.get(connectionId)
const added = !this.connections.get(connectionId);
if (added) {
const baseUrl = subscription.baseUrl;
const topic = subscription.topic;
@ -68,17 +73,22 @@ 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"})`);
console.log(
`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${
user ? user.username : "anonymous"
})`
);
connection.start();
}
});
// Delete old connections
deletedIds.forEach(id => {
deletedIds.forEach((id) => {
console.log(`[ConnectionManager] Closing connection ${id}`);
const connection = this.connections.get(id);
this.connections.delete(id);
@ -91,7 +101,10 @@ 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
);
}
}
}
@ -101,17 +114,24 @@ 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 ?? ""}`)
return user
? hashCode(
`${subscription.id}|${user.username}|${user.password ?? ""}|${
user.token ?? ""
}`
)
: hashCode(`${subscription.id}`);
}
};
const connectionManager = new ConnectionManager();
export default connectionManager;

View File

@ -1,4 +1,11 @@
import {formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl} from "./utils";
import {
formatMessage,
formatTitleWithDefault,
openUrl,
playSound,
topicDisplayName,
topicShortUrl,
} from "./utils";
import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
import logo from "../img/ntfy.png";
@ -23,10 +30,12 @@ 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
icon: logo,
});
if (notification.click) {
n.onclick = (e) => openUrl(notification.click);
@ -46,7 +55,7 @@ class Notifier {
}
granted() {
return this.supported() && Notification.permission === 'granted';
return this.supported() && Notification.permission === "granted";
}
maybeRequestPermission(cb) {
@ -56,7 +65,7 @@ class Notifier {
}
if (!this.granted()) {
Notification.requestPermission().then((permission) => {
const granted = permission === 'granted';
const granted = permission === "granted";
cb(granted);
});
}
@ -66,7 +75,7 @@ class Notifier {
if (subscription.mutedUntil === 1) {
return false;
}
const priority = (notification.priority) ? notification.priority : 3;
const priority = notification.priority ? notification.priority : 3;
const minPriority = await prefs.minPriority();
if (priority < minPriority) {
return false;
@ -79,7 +88,7 @@ class Notifier {
}
browserSupported() {
return 'Notification' in window;
return "Notification" in window;
}
/**
@ -87,9 +96,11 @@ class Notifier {
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
*/
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,12 +34,18 @@ 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

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

View File

@ -20,12 +20,15 @@ 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

@ -5,16 +5,18 @@ class SubscriptionManager {
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
async all() {
const subscriptions = await db.subscriptions.toArray();
await Promise.all(subscriptions.map(async s => {
await Promise.all(
subscriptions.map(async (s) => {
s.new = await db.notifications
.where({ subscriptionId: s.id, new: 1 })
.count();
}));
})
);
return subscriptions;
}
async get(subscriptionId) {
return await db.subscriptions.get(subscriptionId)
return await db.subscriptions.get(subscriptionId);
}
async add(baseUrl, topic, internal) {
@ -29,24 +31,30 @@ class SubscriptionManager {
topic: topic,
mutedUntil: 0,
last: null,
internal: internal || false
internal: internal || false,
};
await db.subscriptions.put(subscription);
return subscription;
}
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!
reservation: reservation, // May be null!
});
remoteIds.push(local.id);
}
@ -68,9 +76,7 @@ class SubscriptionManager {
async remove(subscriptionId) {
await db.subscriptions.delete(subscriptionId);
await db.notifications
.where({subscriptionId: subscriptionId})
.delete();
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
}
async first() {
@ -84,7 +90,7 @@ class SubscriptionManager {
return db.notifications
.orderBy("time") // Sort by time first
.filter(n => n.subscriptionId === subscriptionId)
.filter((n) => n.subscriptionId === subscriptionId)
.reverse()
.toArray();
}
@ -106,7 +112,7 @@ class SubscriptionManager {
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab
await db.subscriptions.update(subscriptionId, {
last: notification.id
last: notification.id,
});
} catch (e) {
console.error(`[SubscriptionManager] Error adding notification`, e);
@ -116,12 +122,13 @@ 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, {
last: lastNotificationId
last: lastNotificationId,
});
}
@ -143,15 +150,11 @@ class SubscriptionManager {
}
async deleteNotifications(subscriptionId) {
await db.notifications
.where({subscriptionId: subscriptionId})
.delete();
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
}
async markNotificationRead(notificationId) {
await db.notifications
.where({id: notificationId})
.modify({new: 0});
await db.notifications.where({ id: notificationId }).modify({ new: 0 });
}
async markNotificationsRead(subscriptionId) {
@ -162,19 +165,19 @@ class SubscriptionManager {
async setMutedUntil(subscriptionId, mutedUntil) {
await db.subscriptions.update(subscriptionId, {
mutedUntil: mutedUntil
mutedUntil: mutedUntil,
});
}
async setDisplayName(subscriptionId, displayName) {
await db.subscriptions.update(subscriptionId, {
displayName: displayName
displayName: displayName,
});
}
async setReservation(subscriptionId, reservation) {
await db.subscriptions.update(subscriptionId, {
reservation: reservation
reservation: reservation,
});
}
@ -183,9 +186,7 @@ class SubscriptionManager {
}
async pruneNotifications(thresholdTimestamp) {
await db.notifications
.where("time").below(thresholdTimestamp)
.delete();
await db.notifications.where("time").below(thresholdTimestamp).delete();
}
}

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@ -15,7 +15,12 @@ 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) {
@ -38,29 +43,38 @@ const maybeToJson = async (response) => {
} catch (e) {
return null;
}
}
};
export class UnauthorizedError extends Error {
constructor() { super("Unauthorized"); }
constructor() {
super("Unauthorized");
}
}
export class UserExistsError extends Error {
static CODE = 40901; // errHTTPConflictUserExists
constructor() { super("Username already exists"); }
constructor() {
super("Username already exists");
}
}
export class TopicReservedError extends Error {
static CODE = 40902; // errHTTPConflictTopicReserved
constructor() { super("Topic already reserved"); }
constructor() {
super("Topic already reserved");
}
}
export class AccountCreateLimitReachedError extends Error {
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
constructor() { super("Account creation limit reached"); }
constructor() {
super("Account creation limit reached");
}
}
export class IncorrectPasswordError extends Error {
static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation
constructor() { super("Password incorrect"); }
constructor() {
super("Password incorrect");
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,9 +1,15 @@
import * as React from 'react';
import {createContext, Suspense, useContext, useEffect, useState} from 'react';
import Box from '@mui/material/Box';
import {ThemeProvider} from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import Toolbar from '@mui/material/Toolbar';
import * as React from "react";
import {
createContext,
Suspense,
useContext,
useEffect,
useState,
} from "react";
import Box from "@mui/material/Box";
import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import Toolbar from "@mui/material/Toolbar";
import { AllSubscriptions, SingleSubscription } from "./Notifications";
import theme from "./theme";
import Navigation from "./Navigation";
@ -13,11 +19,21 @@ 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!
@ -44,8 +60,14 @@ 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>
@ -54,30 +76,39 @@ const App = () => {
</BrowserRouter>
</Suspense>
);
}
};
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 [selected] = (subscriptionsWithoutInternal || []).filter(s => {
return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic)
|| (config.base_url === s.baseUrl && params.topic === s.topic)
const subscriptionsWithoutInternal = subscriptions?.filter(
(s) => !s.internal
);
const newNotificationsCount =
subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
const [selected] = (subscriptionsWithoutInternal || []).filter((s) => {
return (
(params.baseUrl &&
expandUrl(params.baseUrl).includes(s.baseUrl) &&
params.topic === s.topic) ||
(config.base_url === s.baseUrl && params.topic === s.topic)
);
});
useConnectionListeners(account, subscriptions, users);
useAccountListener(setAccount)
useAccountListener(setAccount);
useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
return (
<Box sx={{display: 'flex'}}>
<Box sx={{ display: "flex" }}>
<ActionBar
selected={selected}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
@ -89,14 +120,18 @@ const Layout = () => {
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onNotificationGranted={setNotificationsGranted}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
onPublishMessageClick={() =>
setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)
}
/>
<Main>
<Toolbar />
<Outlet context={{
<Outlet
context={{
subscriptions: subscriptionsWithoutInternal,
selected: selected
}}/>
selected: selected,
}}
/>
</Main>
<Messaging
selected={selected}
@ -105,7 +140,7 @@ const Layout = () => {
/>
</Box>
);
}
};
const Main = (props) => {
return (
@ -113,14 +148,17 @@ const Main = (props) => {
id="main"
component="main"
sx={{
display: 'flex',
display: "flex",
flexGrow: 1,
flexDirection: 'column',
flexDirection: "column",
padding: 3,
width: { sm: `calc(100% - ${Navigation.width}px)` },
height: '100vh',
overflow: 'auto',
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
height: "100vh",
overflow: "auto",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}}
>
{props.children}
@ -133,7 +171,10 @@ 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 />
@ -141,7 +182,8 @@ const Loader = () => (
);
const updateTitle = (newNotificationsCount) => {
document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy";
}
document.title =
newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
};
export default App;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import {useState} from 'react';
import * as React from "react";
import { useState } from "react";
import Typography from "@mui/material/Typography";
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
@ -28,7 +28,9 @@ 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) {
@ -43,16 +45,19 @@ const Login = () => {
if (!config.enable_login) {
return (
<AvatarBox>
<Typography sx={{ typography: 'h6' }}>{t("login_disabled")}</Typography>
<Typography sx={{ typography: "h6" }}>{t("login_disabled")}</Typography>
</AvatarBox>
);
}
return (
<AvatarBox>
<Typography sx={{ typography: 'h6' }}>
{t("login_title")}
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}>
<Typography sx={{ typography: "h6" }}>{t("login_title")}</Typography>
<Box
component="form"
onSubmit={handleSubmit}
noValidate
sx={{ mt: 1, maxWidth: 400 }}
>
<TextField
margin="dense"
required
@ -61,7 +66,7 @@ const Login = () => {
label={t("signup_form_username")}
name="username"
value={username}
onChange={ev => setUsername(ev.target.value.trim())}
onChange={(ev) => setUsername(ev.target.value.trim())}
autoFocus
/>
<TextField
@ -73,7 +78,7 @@ const Login = () => {
type={showPassword ? "text" : "password"}
id="password"
value={password}
onChange={ev => setPassword(ev.target.value.trim())}
onChange={(ev) => setPassword(ev.target.value.trim())}
autoComplete="current-password"
InputProps={{
endAdornment: (
@ -87,7 +92,7 @@ const Login = () => {
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
),
}}
/>
<Button
@ -99,24 +104,32 @@ const Login = () => {
>
{t("login_form_button_submit")}
</Button>
{error &&
<Box sx={{
{error && (
<Box
sx={{
mb: 1,
display: 'flex',
display: "flex",
flexGrow: 1,
justifyContent: 'center',
}}>
justifyContent: "center",
}}
>
<WarningAmberIcon color="error" sx={{ mr: 1 }} />
<Typography sx={{color: 'error.main'}}>{error}</Typography>
<Typography sx={{ color: "error.main" }}>{error}</Typography>
</Box>
}
)}
<Box sx={{ width: "100%" }}>
{/* This is where the password reset link would go */}
{config.enable_signup && <div style={{float: "right"}}><NavLink to={routes.signup} variant="body1">{t("login_link_signup")}</NavLink></div>}
{config.enable_signup && (
<div style={{ float: "right" }}>
<NavLink to={routes.signup} variant="body1">
{t("login_link_signup")}
</NavLink>
</div>
)}
</Box>
</Box>
</AvatarBox>
);
}
};
export default Login;

View File

@ -1,5 +1,5 @@
import * as React from 'react';
import {useState} from 'react';
import * as React from "react";
import { useState } from "react";
import Navigation from "./Navigation";
import Paper from "@mui/material/Paper";
import IconButton from "@mui/material/IconButton";
@ -7,7 +7,7 @@ import TextField from "@mui/material/TextField";
import SendIcon from "@mui/icons-material/Send";
import api from "../app/Api";
import PublishDialog from "./PublishDialog";
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { Portal, Snackbar } from "@mui/material";
import { useTranslation } from "react-i18next";
@ -24,17 +24,19 @@ const Messaging = (props) => {
const handleDialogClose = () => {
props.onDialogOpenModeChange("");
setDialogKey(prev => prev+1);
setDialogKey((prev) => prev + 1);
};
return (
<>
{subscription && <MessageBar
{subscription && (
<MessageBar
subscription={subscription}
message={message}
onMessageChange={setMessage}
onOpenDialogClick={handleOpenDialogClick}
/>}
/>
)}
<PublishDialog
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
openMode={dialogOpenMode}
@ -42,12 +44,18 @@ 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)
}
/>
</>
);
}
};
const MessageBar = (props) => {
const { t } = useTranslation();
@ -55,7 +63,11 @@ 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);
@ -67,15 +79,24 @@ const MessageBar = (props) => {
elevation={3}
sx={{
display: "flex",
position: 'fixed',
position: "fixed",
bottom: 0,
right: 0,
padding: 2,
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
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
@ -88,15 +109,21 @@ const MessageBar = (props) => {
fullWidth
variant="standard"
value={props.message}
onChange={ev => props.onMessageChange(ev.target.value)}
onChange={(ev) => props.onMessageChange(ev.target.value)}
onKeyPress={(ev) => {
if (ev.key === 'Enter') {
if (ev.key === "Enter") {
ev.preventDefault();
handleSendClick();
}
}}
/>
<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>

View File

@ -12,7 +12,16 @@ 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";
@ -20,18 +29,28 @@ 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";
import ArticleIcon from '@mui/icons-material/Article';
import ArticleIcon from "@mui/icons-material/Article";
import { Trans, useTranslation } from "react-i18next";
import session from "../app/Session";
import accountApi, { Permission, Role } from "../app/AccountApi";
import CelebrationIcon from '@mui/icons-material/Celebration';
import 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";
@ -53,8 +72,8 @@ const Navigation = (props) => {
onClose={props.onMobileDrawerToggle}
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
display: { xs: "block", sm: "none" },
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
}}
>
{navigationList}
@ -65,8 +84,8 @@ const Navigation = (props) => {
variant="permanent"
role="menubar"
sx={{
display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
display: { xs: "none", sm: "block" },
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
}}
>
{navigationList}
@ -86,18 +105,23 @@ const NavList = (props) => {
const handleSubscribeReset = () => {
setSubscribeDialogOpen(false);
setSubscribeDialogKey(prev => prev+1);
}
setSubscribeDialogKey((prev) => prev + 1);
};
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 = () => {
@ -110,27 +134,55 @@ 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' } }}/>
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
<List component="nav" sx={{ paddingTop: navListPadding }}>
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert/>}
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
{!showSubscriptionsList &&
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
<ListItemIcon><ChatBubble/></ListItemIcon>
{showNotificationBrowserNotSupportedBox && (
<NotificationBrowserNotSupportedAlert />
)}
{showNotificationContextNotSupportedBox && (
<NotificationContextNotSupportedAlert />
)}
{showNotificationGrantBox && (
<NotificationGrantAlert
onRequestPermissionClick={handleRequestNotificationPermission}
/>
)}
{!showSubscriptionsList && (
<ListItemButton
onClick={() => navigate(routes.app)}
selected={location.pathname === config.app_root}
>
<ListItemIcon>
<ChatBubble />
</ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")} />
</ListItemButton>}
{showSubscriptionsList &&
</ListItemButton>
)}
{showSubscriptionsList && (
<>
<ListSubheader>{t("nav_topics_title")}</ListSubheader>
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
<ListItemIcon><ChatBubble/></ListItemIcon>
<ListItemButton
onClick={() => navigate(routes.app)}
selected={location.pathname === config.app_root}
>
<ListItemIcon>
<ChatBubble />
</ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")} />
</ListItemButton>
<SubscriptionList
@ -138,32 +190,47 @@ const NavList = (props) => {
selectedSubscription={props.selectedSubscription}
/>
<Divider sx={{ my: 1 }} />
</>}
{session.exists() &&
<ListItemButton onClick={handleAccountClick} selected={location.pathname === routes.account}>
<ListItemIcon><Person/></ListItemIcon>
</>
)}
{session.exists() && (
<ListItemButton
onClick={handleAccountClick}
selected={location.pathname === routes.account}
>
<ListItemIcon>
<Person />
</ListItemIcon>
<ListItemText primary={t("nav_button_account")} />
</ListItemButton>
}
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
<ListItemIcon><SettingsIcon/></ListItemIcon>
)}
<ListItemButton
onClick={() => navigate(routes.settings)}
selected={location.pathname === routes.settings}
>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_settings")} />
</ListItemButton>
<ListItemButton onClick={() => openUrl("/docs")}>
<ListItemIcon><ArticleIcon/></ListItemIcon>
<ListItemIcon>
<ArticleIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_documentation")} />
</ListItemButton>
<ListItemButton onClick={() => props.onPublishMessageClick()}>
<ListItemIcon><Send/></ListItemIcon>
<ListItemIcon>
<Send />
</ListItemIcon>
<ListItemText primary={t("nav_button_publish_message")} />
</ListItemButton>
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
<ListItemIcon><AddIcon/></ListItemIcon>
<ListItemIcon>
<AddIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_subscribe")} />
</ListItemButton>
{showUpgradeBanner &&
<UpgradeBanner/>
}
{showUpgradeBanner && <UpgradeBanner />}
</List>
<SubscribeDialog
key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed
@ -182,21 +249,26 @@ const UpgradeBanner = () => {
const [dialogOpen, setDialogOpen] = useState(false);
const handleClick = () => {
setDialogKey(k => k + 1);
setDialogKey((k) => k + 1);
setDialogOpen(true);
};
return (
<Box sx={{
<Box
sx={{
position: "fixed",
width: `${Navigation.width - 1}px`,
bottom: 0,
mt: 'auto',
background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
}}>
mt: "auto",
background:
"linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
}}
>
<Divider />
<ListItemButton onClick={handleClick} sx={{ pt: 2, pb: 2 }}>
<ListItemIcon><CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large"/></ListItemIcon>
<ListItemIcon>
<CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large" />
</ListItemIcon>
<ListItemText
sx={{ ml: 1 }}
primary={t("nav_upgrade_banner_label")}
@ -205,15 +277,16 @@ 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"
}
WebkitTextFillColor: "transparent",
},
}}
secondaryTypographyProps={{
style: {
fontSize: "1rem"
}
fontSize: "1rem",
},
}}
/>
</ListItemButton>
@ -228,21 +301,27 @@ const UpgradeBanner = () => {
const SubscriptionList = (props) => {
const sortedSubscriptions = props.subscriptions
.filter(s => !s.internal)
.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 (
<>
{sortedSubscriptions.map(subscription =>
{sortedSubscriptions.map((subscription) => (
<SubscriptionItem
key={subscription.id}
subscription={subscription}
selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id}
/>)}
selected={
props.selectedSubscription &&
props.selectedSubscription.id === subscription.id
}
/>
))}
</>
);
}
};
const SubscriptionItem = (props) => {
const { t } = useTranslation();
@ -250,14 +329,24 @@ const SubscriptionItem = (props) => {
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
const subscription = props.subscription;
const iconBadge = (subscription.new <= 99) ? subscription.new : "99+";
const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
const displayName = topicDisplayName(subscription);
const ariaLabel = (subscription.state === ConnectionState.Connecting)
const ariaLabel =
subscription.state === ConnectionState.Connecting
? `${displayName} (${t("nav_button_connecting")})`
: displayName;
const icon = (subscription.state === ConnectionState.Connecting)
? <CircularProgress size="24px"/>
: <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>;
const icon =
subscription.state === ConnectionState.Connecting ? (
<CircularProgress size="24px" />
) : (
<Badge
badgeContent={iconBadge}
invisible={subscription.new === 0}
color="primary"
>
<ChatBubbleOutlineIcon />
</Badge>
);
const handleClick = async () => {
navigate(routes.forSubscription(subscription));
@ -266,30 +355,58 @@ 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} primaryTypographyProps={{ style: { overflow: "hidden", textOverflow: "ellipsis" } }}/>
{subscription.reservation?.everyone &&
<ListItemText
primary={displayName}
primaryTypographyProps={{
style: { overflow: "hidden", textOverflow: "ellipsis" },
}}
/>
{subscription.reservation?.everyone && (
<ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
{subscription.reservation?.everyone === Permission.READ_WRITE &&
<Tooltip title={t("prefs_reservations_table_everyone_read_write")}><PermissionReadWrite size="small"/></Tooltip>
}
{subscription.reservation?.everyone === Permission.READ_ONLY &&
<Tooltip title={t("prefs_reservations_table_everyone_read_only")}><PermissionRead size="small"/></Tooltip>
}
{subscription.reservation?.everyone === Permission.WRITE_ONLY &&
<Tooltip title={t("prefs_reservations_table_everyone_write_only")}><PermissionWrite size="small"/></Tooltip>
}
{subscription.reservation?.everyone === Permission.DENY_ALL &&
<Tooltip title={t("prefs_reservations_table_everyone_deny_all")}><PermissionDenyAll size="small"/></Tooltip>
}
{subscription.reservation?.everyone === Permission.READ_WRITE && (
<Tooltip
title={t("prefs_reservations_table_everyone_read_write")}
>
<PermissionReadWrite size="small" />
</Tooltip>
)}
{subscription.reservation?.everyone === Permission.READ_ONLY && (
<Tooltip title={t("prefs_reservations_table_everyone_read_only")}>
<PermissionRead size="small" />
</Tooltip>
)}
{subscription.reservation?.everyone === Permission.WRITE_ONLY && (
<Tooltip
title={t("prefs_reservations_table_everyone_write_only")}
>
<PermissionWrite size="small" />
</Tooltip>
)}
{subscription.reservation?.everyone === Permission.DENY_ALL && (
<Tooltip title={t("prefs_reservations_table_everyone_deny_all")}>
<PermissionDenyAll size="small" />
</Tooltip>
)}
</ListItemIcon>
}
{subscription.mutedUntil > 0 &&
<ListItemIcon edge="end" sx={{ minWidth: "26px" }} aria-label={t("nav_button_muted")}>
<Tooltip title={t("nav_button_muted")}><NotificationsOffOutlined /></Tooltip>
)}
{subscription.mutedUntil > 0 && (
<ListItemIcon
edge="end"
sx={{ minWidth: "26px" }}
aria-label={t("nav_button_muted")}
>
<Tooltip title={t("nav_button_muted")}>
<NotificationsOffOutlined />
</Tooltip>
</ListItemIcon>
}
)}
<ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
<IconButton
size="small"
@ -322,7 +439,7 @@ const NotificationGrantAlert = (props) => {
<AlertTitle>{t("alert_grant_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_grant_description")}</Typography>
<Button
sx={{float: 'right'}}
sx={{ float: "right" }}
color="inherit"
size="small"
onClick={props.onRequestPermissionClick}
@ -341,7 +458,9 @@ 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 />
</>
@ -358,7 +477,13 @@ 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

@ -9,7 +9,7 @@ import {
Modal,
Snackbar,
Stack,
Tooltip
Tooltip,
} from "@mui/material";
import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography";
@ -24,12 +24,16 @@ import {
openUrl,
shortUrl,
topicShortUrl,
unmatchedTags
unmatchedTags,
} from "../app/utils";
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 CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import {
LightboxBackdrop,
Paragraph,
VerticallyCenteredContainer,
} from "./styles";
import { useLiveQuery } from "dexie-react-hooks";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
@ -64,7 +68,10 @@ 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) {
@ -72,19 +79,34 @@ 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) => {
const { t } = useTranslation();
@ -101,13 +123,13 @@ const NotificationList = (props) => {
if (main) {
main.scrollTo(0, 0);
}
}
};
}, [props.id]);
return (
<InfiniteScroll
dataLength={count}
next={() => setMaxCount(prev => prev + pageSize)}
next={() => setMaxCount((prev) => prev + pageSize)}
hasMore={count < notifications.length}
loader={<>Loading ...</>}
scrollThreshold={0.7}
@ -119,16 +141,17 @@ const NotificationList = (props) => {
aria-label={t("notifications_list")}
sx={{
marginTop: 3,
marginBottom: (props.messageBar) ? "100px" : 3 // Hack to avoid hiding notifications behind the message bar
marginBottom: props.messageBar ? "100px" : 3, // Hack to avoid hiding notifications behind the message bar
}}
>
<Stack spacing={3}>
{notifications.slice(0, count).map(notification =>
{notifications.slice(0, count).map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onShowSnack={() => setSnackOpen(true)}
/>)}
/>
))}
<Snackbar
open={snackOpen}
autoHideDuration={3000}
@ -139,7 +162,7 @@ const NotificationList = (props) => {
</Container>
</InfiniteScroll>
);
}
};
const NotificationItem = (props) => {
const { t } = useTranslation();
@ -147,81 +170,138 @@ const NotificationItem = (props) => {
const attachment = notification.attachment;
const date = formatShortDateTime(notification.time);
const otherTags = unmatchedTags(notification.tags);
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
const handleDelete = async () => {
console.log(`[Notifications] Deleting notification ${notification.id}`);
await subscriptionManager.deleteNotification(notification.id)
}
await subscriptionManager.deleteNotification(notification.id);
};
const handleMarkRead = async () => {
console.log(`[Notifications] Marking notification ${notification.id} as read`);
await subscriptionManager.markNotificationRead(notification.id)
}
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 &&
{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>}
</Tooltip>
)}
<Typography sx={{ fontSize: 14 }} color="text.secondary">
{date}
{[1,2,4,5].includes(notification.priority) &&
{[1, 2, 4, 5].includes(notification.priority) && (
<img
src={priorityFiles[notification.priority]}
alt={t("notifications_priority_x", { priority: notification.priority})}
style={{ verticalAlign: 'bottom' }}
/>}
{notification.new === 1 &&
<svg style={{ width: '8px', height: '8px', marginLeft: '4px' }} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-label={t("notifications_new_indicator")}>
alt={t("notifications_priority_x", {
priority: notification.priority,
})}
style={{ verticalAlign: "bottom" }}
/>
)}
{notification.new === 1 && (
<svg
style={{ width: "8px", height: "8px", marginLeft: "4px" }}
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
aria-label={t("notifications_new_indicator")}
>
<circle cx="50" cy="50" r="50" fill="#338574" />
</svg>}
</svg>
)}
</Typography>
{notification.title && <Typography variant="h5" component="div" role="rowheader">{formatTitle(notification)}</Typography>}
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>
{autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
{notification.title && (
<Typography variant="h5" component="div" role="rowheader">
{formatTitle(notification)}
</Typography>
)}
<Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
{autolink(
maybeAppendActionErrors(formatMessage(notification), notification)
)}
</Typography>
{attachment && <Attachment attachment={attachment} />}
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">{t("notifications_tags")}: {tags}</Typography>}
{tags && (
<Typography sx={{ fontSize: 14 }} color="text.secondary">
{t("notifications_tags")}: {tags}
</Typography>
)}
</CardContent>
{showActions &&
{showActions && (
<CardActions sx={{ paddingTop: 0 }}>
{hasAttachmentActions && <>
{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>
<Tooltip
title={t("notifications_attachment_open_title", {
url: attachment.url,
})}
>
<Button onClick={() => openUrl(attachment.url)}>
{t("notifications_attachment_open_button")}
</Button>
</Tooltip>
</>}
{hasClickAction && <>
</>
)}
{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>
<Tooltip
title={t("notifications_actions_open_url_title", {
url: notification.click,
})}
>
<Button onClick={() => openUrl(notification.click)}>
{t("notifications_click_open_button")}
</Button>
</Tooltip>
</>}
</>
)}
{hasUserActions && <UserActions notification={notification} />}
</CardActions>}
</CardActions>
)}
</Card>
);
}
};
/**
* Replace links with <Link/> components; this is a combination of the genius function
@ -231,9 +311,21 @@ const NotificationItem = (props) => {
* [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9
*/
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">{shortUrl(parts[i])}</Link>;
parts[i] = (
<Link
key={i}
href={parts[i]}
underline="hover"
target="_blank"
rel="noreferrer,noopener"
>
{shortUrl(parts[i])}
</Link>
);
}
return <>{parts}</>;
};
@ -242,7 +334,7 @@ const priorityFiles = {
1: priority1,
2: priority2,
4: priority4,
5: priority5
5: priority5,
};
const Attachment = (props) => {
@ -250,7 +342,8 @@ 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) {
@ -263,25 +356,40 @@ const Attachment = (props) => {
infos.push(formatBytes(attachment.size));
}
if (expires) {
infos.push(t("notifications_attachment_link_expires", { date: formatShortDateTime(attachment.expires) }));
infos.push(
t("notifications_attachment_link_expires", {
date: formatShortDateTime(attachment.expires),
})
);
}
if (expired) {
infos.push(t("notifications_attachment_link_expired"));
}
const maybeInfoText = (infos.length > 0) ? <><br/>{infos.join(", ")}</> : null;
const maybeInfoText =
infos.length > 0 ? (
<>
<br />
{infos.join(", ")}
</>
) : null;
// If expired, just show infos without click target
if (expired) {
return (
<Box sx={{
display: 'flex',
alignItems: 'center',
<Box
sx={{
display: "flex",
alignItems: "center",
marginTop: 2,
padding: 1,
borderRadius: '4px',
}}>
borderRadius: "4px",
}}
>
<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>
@ -291,26 +399,31 @@ const Attachment = (props) => {
// Not expired
return (
<ButtonBase sx={{
<ButtonBase
sx={{
marginTop: 2,
}}>
}}
>
<Link
href={attachment.url}
target="_blank"
rel="noopener"
underline="none"
sx={{
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
padding: 1,
borderRadius: '4px',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.05)'
}
borderRadius: "4px",
"&:hover": {
backgroundColor: "rgba(0, 0, 0, 0.05)",
},
}}
>
<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>
@ -332,12 +445,12 @@ const Image = (props) => {
onClick={() => setOpen(true)}
sx={{
marginTop: 2,
borderRadius: '4px',
borderRadius: "4px",
boxShadow: 2,
width: 1,
maxHeight: '400px',
objectFit: 'cover',
cursor: 'pointer'
maxHeight: "400px",
objectFit: "cover",
cursor: "pointer",
}}
/>
<Modal
@ -354,10 +467,10 @@ const Image = (props) => {
sx={{
maxWidth: 1,
maxHeight: 1,
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
padding: 4,
}}
/>
@ -365,12 +478,19 @@ const Image = (props) => {
</Modal>
</>
);
}
};
const UserActions = (props) => {
return (
<>{props.notification.actions.map(action =>
<UserAction key={action.id} notification={props.notification} action={action}/>)}</>
<>
{props.notification.actions.map((action) => (
<UserAction
key={action.id}
notification={props.notification}
action={action}
/>
))}
</>
);
};
@ -381,27 +501,51 @@ const UserAction = (props) => {
if (action.action === "broadcast") {
return (
<Tooltip title={t("notifications_actions_not_supported")}>
<span><Button disabled aria-label={t("notifications_actions_not_supported")}>{action.label}</Button></span>
<span>
<Button
disabled
aria-label={t("notifications_actions_not_supported")}
>
{action.label}
</Button>
</span>
</Tooltip>
);
} 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", { url: action.url })}
>{action.label}</Button>
aria-label={t("notifications_actions_open_url_title", {
url: action.url,
})}
>
{action.label}
</Button>
</Tooltip>
);
} 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", { method: method, url: action.url })}>
<Tooltip
title={t("notifications_actions_http_request_title", {
method: method,
url: action.url,
})}
>
<Button
onClick={() => performHttpAction(notification, action)}
aria-label={t("notifications_actions_http_request_title", { method: method, url: action.url })}
>{label}</Button>
aria-label={t("notifications_actions_http_request_title", {
method: method,
url: action.url,
})}
>
{label}
</Button>
</Tooltip>
);
}
@ -417,30 +561,40 @@ const performHttpAction = async (notification, action) => {
headers: action.headers ?? {},
// This must not null-coalesce to a non nullish value. Otherwise, the fetch API
// will reject it for "having a body"
body: action.body
body: action.body,
});
console.log(`[Notifications] HTTP user action response`, response);
const success = response.status >= 200 && response.status <= 299;
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.`
);
}
};
const updateActionStatus = (notification, action, progress, error) => {
notification.actions = notification.actions.map(a => {
notification.actions = notification.actions.map((a) => {
if (a.id !== action.id) {
return a;
}
return { ...a, progress: progress, error: error };
});
subscriptionManager.updateNotification(notification);
}
};
const ACTION_PROGRESS_ONGOING = 1;
const ACTION_PROGRESS_SUCCESS = 2;
@ -449,26 +603,31 @@ const ACTION_PROGRESS_FAILED = 3;
const ACTION_LABEL_SUFFIX = {
[ACTION_PROGRESS_ONGOING]: " …",
[ACTION_PROGRESS_SUCCESS]: " ✔",
[ACTION_PROGRESS_FAILED]: " ❌"
[ACTION_PROGRESS_FAILED]: " ❌",
};
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")}/><br />
<img
src={logoOutline}
height="64"
width="64"
alt={t("action_bar_logo_alt")}
/>
<br />
{t("notifications_none_for_topic_title")}
</Typography>
<Paragraph>
{t("notifications_none_for_topic_description")}
</Paragraph>
<Paragraph>{t("notifications_none_for_topic_description")}</Paragraph>
<Paragraph>
{t("notifications_example")}:<br />
<tt>
$ curl -d "Hi" {shortUrl}
</tt>
<tt>$ curl -d "Hi" {shortUrl}</tt>
</Paragraph>
<Paragraph>
<ForMoreDetails />
@ -484,17 +643,19 @@ 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")}/><br />
<img
src={logoOutline}
height="64"
width="64"
alt={t("action_bar_logo_alt")}
/>
<br />
{t("notifications_none_for_any_title")}
</Typography>
<Paragraph>
{t("notifications_none_for_any_description")}
</Paragraph>
<Paragraph>{t("notifications_none_for_any_description")}</Paragraph>
<Paragraph>
{t("notifications_example")}:<br />
<tt>
$ curl -d "Hi" {shortUrl}
</tt>
<tt>$ curl -d "Hi" {shortUrl}</tt>
</Paragraph>
<Paragraph>
<ForMoreDetails />
@ -508,12 +669,18 @@ 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")}/><br />
<img
src={logoOutline}
height="64"
width="64"
alt={t("action_bar_logo_alt")}
/>
<br />
{t("notifications_no_subscriptions_title")}
</Typography>
<Paragraph>
{t("notifications_no_subscriptions_description", {
linktext: t("nav_button_subscribe")
linktext: t("nav_button_subscribe"),
})}
</Paragraph>
<Paragraph>
@ -528,8 +695,12 @@ 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" />
),
}}
/>
);
@ -539,8 +710,14 @@ const Loading = () => {
const { t } = useTranslation();
return (
<VerticallyCenteredContainer>
<Typography variant="h5" color="text.secondary" align="center" sx={{ paddingBottom: 1 }}>
<CircularProgress disableShrink sx={{marginBottom: 1}}/><br />
<Typography
variant="h5"
color="text.secondary"
align="center"
sx={{ paddingBottom: 1 }}
>
<CircularProgress disableShrink sx={{ marginBottom: 1 }} />
<br />
{t("notifications_loading")}
</Typography>
</VerticallyCenteredContainer>

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import * as React from 'react';
import {useContext, useEffect, useState} from 'react';
import * as React from "react";
import { useContext, useEffect, useState } from "react";
import {
Alert,
CardActions,
@ -14,15 +14,15 @@ import {
TableHead,
TableRow,
Tooltip,
useMediaQuery
useMediaQuery,
} from "@mui/material";
import Typography from "@mui/material/Typography";
import prefs from "../app/Prefs";
import { Paragraph } from "./styles";
import EditIcon from '@mui/icons-material/Edit';
import EditIcon from "@mui/icons-material/Edit";
import CloseIcon from "@mui/icons-material/Close";
import IconButton from "@mui/material/IconButton";
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import Container from "@mui/material/Container";
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
@ -44,8 +44,17 @@ 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";
@ -87,10 +96,10 @@ const Sound = () => {
await prefs.setSound(ev.target.value);
await maybeUpdateAccountSettings({
notification: {
sound: ev.target.value
}
sound: ev.target.value,
},
});
}
};
if (!sound) {
return null; // While loading
}
@ -98,23 +107,43 @@ const Sound = () => {
if (sound === "none") {
description = t("prefs_notifications_sound_description_none");
} else {
description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label });
description = t("prefs_notifications_sound_description_some", {
sound: sounds[sound].label,
});
}
return (
<Pref labelId={labelId} title={t("prefs_notifications_sound_title")} description={description}>
<div style={{ display: 'flex', width: '100%' }}>
<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>
{Object.entries(sounds).map(s => <MenuItem key={s[0]} value={s[0]}>{s[1].label}</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}
</MenuItem>
))}
</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>
</Pref>
)
);
};
const MinPriority = () => {
@ -125,10 +154,10 @@ const MinPriority = () => {
await prefs.setMinPriority(ev.target.value);
await maybeUpdateAccountSettings({
notification: {
min_priority: ev.target.value
}
min_priority: ev.target.value,
},
});
}
};
if (!minPriority) {
return null; // While loading
}
@ -137,32 +166,53 @@ const MinPriority = () => {
2: t("priority_low"),
3: t("priority_default"),
4: t("priority_high"),
5: t("priority_max")
}
5: t("priority_max"),
};
let description;
if (minPriority === 1) {
description = t("prefs_notifications_min_priority_description_any");
} else if (minPriority === 5) {
description = t("prefs_notifications_min_priority_description_max");
} else {
description = t("prefs_notifications_min_priority_description_x_or_higher", {
description = t(
"prefs_notifications_min_priority_description_x_or_higher",
{
number: minPriority,
name: priorities[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>
)
);
};
const DeleteAfter = () => {
@ -173,35 +223,59 @@ const DeleteAfter = () => {
await prefs.setDeleteAfter(ev.target.value);
await maybeUpdateAccountSettings({
notification: {
delete_after: ev.target.value
}
delete_after: ev.target.value,
},
});
}
if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0"
};
if (deleteAfter === null || deleteAfter === undefined) {
// !deleteAfter will not work with "0"
return null; // While loading
}
const description = (() => {
switch (deleteAfter) {
case 0: return t("prefs_notifications_delete_after_never_description");
case 10800: return t("prefs_notifications_delete_after_three_hours_description");
case 86400: return t("prefs_notifications_delete_after_one_day_description");
case 604800: return t("prefs_notifications_delete_after_one_week_description");
case 2592000: return t("prefs_notifications_delete_after_one_month_description");
case 0:
return t("prefs_notifications_delete_after_never_description");
case 10800:
return t("prefs_notifications_delete_after_three_hours_description");
case 86400:
return t("prefs_notifications_delete_after_one_day_description");
case 604800:
return t("prefs_notifications_delete_after_one_week_description");
case 2592000:
return t("prefs_notifications_delete_after_one_month_description");
}
})();
return (
<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>
)
);
};
const Users = () => {
@ -210,7 +284,7 @@ const Users = () => {
const [dialogOpen, setDialogOpen] = useState(false);
const users = useLiveQuery(() => userManager.all());
const handleAddClick = () => {
setDialogKey(prev => prev+1);
setDialogKey((prev) => prev + 1);
setDialogOpen(true);
};
const handleDialogCancel = () => {
@ -220,7 +294,9 @@ 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);
}
@ -233,7 +309,9 @@ 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>
@ -259,7 +337,7 @@ const UserTable = (props) => {
const [dialogUser, setDialogUser] = useState(null);
const handleEditClick = (user) => {
setDialogKey(prev => prev+1);
setDialogKey((prev) => prev + 1);
setDialogUser(user);
setDialogOpen(true);
};
@ -272,7 +350,9 @@ 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);
}
@ -281,7 +361,9 @@ 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);
}
@ -291,38 +373,59 @@ 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 => (
{props.users?.map((user) => (
<TableRow
key={user.baseUrl}
sx={{'&:last-child td, &:last-child th': {border: 0}}}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
<TableCell
component="th"
scope="row"
sx={{ paddingLeft: 0 }}
aria-label={t("prefs_users_table_user_header")}
>
{user.username}
</TableCell>
<TableCell aria-label={t("prefs_users_table_base_url_header")}>
{user.baseUrl}
</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{(!session.exists() || user.baseUrl !== config.base_url) &&
{(!session.exists() || user.baseUrl !== config.base_url) && (
<>
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
<IconButton
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>
</>
}
{session.exists() && user.baseUrl === config.base_url &&
)}
{session.exists() && user.baseUrl === config.base_url && (
<Tooltip title={t("prefs_users_table_cannot_delete_or_edit")}>
<span>
<IconButton disabled><EditIcon/></IconButton>
<IconButton disabled><CloseIcon/></IconButton>
<IconButton disabled>
<EditIcon />
</IconButton>
<IconButton disabled>
<CloseIcon />
</IconButton>
</span>
</Tooltip>
}
)}
</TableCell>
</TableRow>
))}
@ -344,25 +447,29 @@ const UserDialog = (props) => {
const [baseUrl, setBaseUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const editMode = props.user !== null;
const addButtonEnabled = (() => {
if (editMode) {
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({
baseUrl: baseUrl,
username: username,
password: password
})
password: password,
});
};
useEffect(() => {
if (editMode) {
@ -373,20 +480,26 @@ 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
{!editMode && (
<TextField
autoFocus
margin="dense"
id="baseUrl"
label={t("prefs_users_dialog_base_url_label")}
aria-label={t("prefs_users_dialog_base_url_label")}
value={baseUrl}
onChange={ev => setBaseUrl(ev.target.value)}
onChange={(ev) => setBaseUrl(ev.target.value)}
type="url"
fullWidth
variant="standard"
/>}
/>
)}
<TextField
autoFocus={editMode}
margin="dense"
@ -394,7 +507,7 @@ const UserDialog = (props) => {
label={t("prefs_users_dialog_username_label")}
aria-label={t("prefs_users_dialog_username_label")}
value={username}
onChange={ev => setUsername(ev.target.value)}
onChange={(ev) => setUsername(ev.target.value)}
type="text"
fullWidth
variant="standard"
@ -406,14 +519,16 @@ const UserDialog = (props) => {
aria-label={t("prefs_users_dialog_password_label")}
type="password"
value={password}
onChange={ev => setPassword(ev.target.value)}
onChange={(ev) => setPassword(ev.target.value)}
fullWidth
variant="standard"
/>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>{editMode ? t("common_save") : t("common_add")}</Button>
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>
{editMode ? t("common_save") : t("common_add")}
</Button>
</DialogActions>
</Dialog>
);
@ -440,7 +555,26 @@ 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) {
@ -450,7 +584,7 @@ const Language = () => {
const handleChange = async (ev) => {
await i18n.changeLanguage(ev.target.value);
await maybeUpdateAccountSettings({
language: ev.target.value
language: ev.target.value,
});
};
@ -488,7 +622,7 @@ const Language = () => {
</Select>
</FormControl>
</Pref>
)
);
};
const Reservations = () => {
@ -501,10 +635,11 @@ 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);
setDialogKey((prev) => prev + 1);
setDialogOpen(true);
};
@ -514,14 +649,18 @@ const Reservations = () => {
<Typography variant="h5" sx={{ marginBottom: 2 }}>
{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>}
<Paragraph>{t("prefs_reservations_description")}</Paragraph>
{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>
<Button onClick={handleAddClick} disabled={limitReached}>
{t("prefs_reservations_add_button")}
</Button>
<ReserveAddDialog
key={`reservationAddDialog${dialogKey}`}
open={dialogOpen}
@ -540,18 +679,24 @@ const ReservationsTable = (props) => {
const [editDialogOpen, setEditDialogOpen] = useState(false);
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})))
const localSubscriptions =
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);
setDialogKey((prev) => prev + 1);
setDialogReservation(reservation);
setEditDialogOpen(true);
};
const handleDeleteClick = async (reservation) => {
setDialogKey(prev => prev+1);
setDialogKey((prev) => prev + 1);
setDialogReservation(reservation);
setDeleteDialogOpen(true);
};
@ -564,56 +709,89 @@ 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 => (
{props.reservations.map((reservation) => (
<TableRow
key={reservation.topic}
sx={{'&:last-child td, &:last-child th': { border: 0 }}}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell
component="th"
scope="row"
sx={{ paddingLeft: 0 }}
aria-label={t("prefs_reservations_table_topic_header")}
>
<TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_reservations_table_topic_header")}>
{reservation.topic}
</TableCell>
<TableCell aria-label={t("prefs_reservations_table_access_header")}>
{reservation.everyone === Permission.READ_WRITE &&
{reservation.everyone === Permission.READ_WRITE && (
<>
<PermissionReadWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
<PermissionReadWrite
size="small"
sx={{ verticalAlign: "bottom", mr: 1.5 }}
/>
{t("prefs_reservations_table_everyone_read_write")}
</>
}
{reservation.everyone === Permission.READ_ONLY &&
)}
{reservation.everyone === Permission.READ_ONLY && (
<>
<PermissionRead size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
<PermissionRead
size="small"
sx={{ verticalAlign: "bottom", mr: 1.5 }}
/>
{t("prefs_reservations_table_everyone_read_only")}
</>
}
{reservation.everyone === Permission.WRITE_ONLY &&
)}
{reservation.everyone === Permission.WRITE_ONLY && (
<>
<PermissionWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
<PermissionWrite
size="small"
sx={{ verticalAlign: "bottom", mr: 1.5 }}
/>
{t("prefs_reservations_table_everyone_write_only")}
</>
}
{reservation.everyone === Permission.DENY_ALL &&
)}
{reservation.everyone === Permission.DENY_ALL && (
<>
<PermissionDenyAll size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
<PermissionDenyAll
size="small"
sx={{ verticalAlign: "bottom", mr: 1.5 }}
/>
{t("prefs_reservations_table_everyone_deny_all")}
</>
}
)}
</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{!localSubscriptions[reservation.topic] &&
<Tooltip title={t("prefs_reservations_table_click_to_subscribe")}>
<Chip icon={<Info/>} onClick={() => handleSubscribeClick(reservation)} label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined"/>
{!localSubscriptions[reservation.topic] && (
<Tooltip
title={t("prefs_reservations_table_click_to_subscribe")}
>
<Chip
icon={<Info />}
onClick={() => handleSubscribeClick(reservation)}
label={t("prefs_reservations_table_not_subscribed")}
color="primary"
variant="outlined"
/>
</Tooltip>
}
<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,5 +1,5 @@
import * as React from 'react';
import {useContext, useEffect, useRef, useState} from 'react';
import * as React from "react";
import { useContext, useEffect, useRef, useState } from "react";
import theme from "./theme";
import {
Checkbox,
@ -10,7 +10,7 @@ import {
Link,
Select,
Tooltip,
useMediaQuery
useMediaQuery,
} from "@mui/material";
import TextField from "@mui/material/TextField";
import priority1 from "../img/priority-1.svg";
@ -24,10 +24,17 @@ import DialogContent from "@mui/material/DialogContent";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
import { Close } from "@mui/icons-material";
import 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";
@ -82,10 +89,10 @@ const PublishDialog = (props) => {
const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
const open = !!props.openMode;
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
useEffect(() => {
window.addEventListener('dragenter', () => {
window.addEventListener("dragenter", () => {
props.onDragEnter();
setDropZone(true);
});
@ -109,7 +116,7 @@ const PublishDialog = (props) => {
const updateBaseUrl = (newVal) => {
if (validUrl(newVal)) {
setBaseUrl(newVal.replace(/\/$/, '')); // strip traililng slash after https?://
setBaseUrl(newVal.replace(/\/$/, "")); // strip traililng slash after https?://
} else {
setBaseUrl(newVal);
}
@ -145,19 +152,24 @@ 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;
const body = attachFile ? attachFile : message;
try {
const user = await userManager.get(baseUrl);
const headers = maybeWithAuth({}, user);
const progressFn = (ev) => {
if (ev.loaded > 0 && ev.total > 0) {
setStatus(t("publish_dialog_progress_uploading_detail", {
setStatus(
t("publish_dialog_progress_uploading_detail", {
loaded: formatBytes(ev.loaded),
total: formatBytes(ev.total),
percent: Math.round(ev.loaded * 100.0 / ev.total)
}));
percent: Math.round((ev.loaded * 100.0) / ev.total),
})
);
} else {
setStatus(t("publish_dialog_progress_uploading"));
}
@ -172,7 +184,11 @@ 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);
}
};
@ -182,17 +198,28 @@ 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(t("publish_dialog_attachment_limits_file_and_quota_reached", {
return setAttachFileError(
t("publish_dialog_attachment_limits_file_and_quota_reached", {
fileSizeLimit: formatBytes(fileSizeLimit),
remainingBytes: formatBytes(remainingBytes)
}));
remainingBytes: formatBytes(remainingBytes),
})
);
} else if (fileSizeLimitReached) {
return setAttachFileError(t("publish_dialog_attachment_limits_file_reached", { fileSizeLimit: formatBytes(fileSizeLimit) }));
return setAttachFileError(
t("publish_dialog_attachment_limits_file_reached", {
fileSizeLimit: formatBytes(fileSizeLimit),
})
);
} else if (quotaReached) {
return setAttachFileError(t("publish_dialog_attachment_limits_quota_reached", { remainingBytes: formatBytes(remainingBytes) }));
return setAttachFileError(
t("publish_dialog_attachment_limits_quota_reached", {
remainingBytes: formatBytes(remainingBytes),
})
);
}
setAttachFileError("");
} catch (e) {
@ -238,7 +265,7 @@ const PublishDialog = (props) => {
};
const handleEmojiPick = (emoji) => {
setTags(tags => (tags.trim()) ? `${tags.trim()}, ${emoji}` : emoji);
setTags((tags) => (tags.trim() ? `${tags.trim()}, ${emoji}` : emoji));
};
const handleEmojiClose = () => {
@ -250,37 +277,55 @@ const PublishDialog = (props) => {
2: { label: t("publish_dialog_priority_low"), file: priority2 },
3: { label: t("publish_dialog_priority_default"), file: priority3 },
4: { label: t("publish_dialog_priority_high"), file: priority4 },
5: { label: t("publish_dialog_priority_max"), file: priority5 }
5: { label: t("publish_dialog_priority_max"), file: priority5 },
};
return (
<>
{dropZone && <DropArea
{dropZone && (
<DropArea
onDrop={handleAttachFileDrop}
onDragLeave={handleAttachFileDragLeave}/>
}
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{(baseUrl && topic) ? t("publish_dialog_title_topic", { topic: topicShortUrl(baseUrl, topic) }) : t("publish_dialog_title_no_topic")}</DialogTitle>
onDragLeave={handleAttachFileDragLeave}
/>
)}
<Dialog
maxWidth="md"
open={open}
onClose={props.onCancel}
fullScreen={fullScreen}
>
<DialogTitle>
{baseUrl && topic
? t("publish_dialog_title_topic", {
topic: topicShortUrl(baseUrl, topic),
})
: t("publish_dialog_title_no_topic")}
</DialogTitle>
<DialogContent>
{dropZone && <DropBox />}
{showTopicUrl &&
<ClosableRow closable={!!props.baseUrl && !!props.topic} disabled={disabled} closeLabel={t("publish_dialog_topic_reset")} onClose={() => {
{showTopicUrl && (
<ClosableRow
closable={!!props.baseUrl && !!props.topic}
disabled={disabled}
closeLabel={t("publish_dialog_topic_reset")}
onClose={() => {
setBaseUrl(props.baseUrl);
setTopic(props.topic);
setShowTopicUrl(false);
}}>
}}
>
<TextField
margin="dense"
label={t("publish_dialog_base_url_label")}
placeholder={t("publish_dialog_base_url_placeholder")}
value={baseUrl}
onChange={ev => updateBaseUrl(ev.target.value)}
onChange={(ev) => updateBaseUrl(ev.target.value)}
disabled={disabled}
type="url"
variant="standard"
sx={{ flexGrow: 1, marginRight: 1 }}
inputProps={{
"aria-label": t("publish_dialog_base_url_label")
"aria-label": t("publish_dialog_base_url_label"),
}}
/>
<TextField
@ -288,30 +333,30 @@ const PublishDialog = (props) => {
label={t("publish_dialog_topic_label")}
placeholder={t("publish_dialog_topic_placeholder")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
onChange={(ev) => setTopic(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
autoFocus={!messageFocused}
sx={{ flexGrow: 1 }}
inputProps={{
"aria-label": t("publish_dialog_topic_label")
"aria-label": t("publish_dialog_topic_label"),
}}
/>
</ClosableRow>
}
)}
<TextField
margin="dense"
label={t("publish_dialog_title_label")}
placeholder={t("publish_dialog_title_placeholder")}
value={title}
onChange={ev => setTitle(ev.target.value)}
onChange={(ev) => setTitle(ev.target.value)}
disabled={disabled}
type="text"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("publish_dialog_title_label")
"aria-label": t("publish_dialog_title_label"),
}}
/>
<TextField
@ -319,7 +364,7 @@ const PublishDialog = (props) => {
label={t("publish_dialog_message_label")}
placeholder={t("publish_dialog_message_placeholder")}
value={message}
onChange={ev => setMessage(ev.target.value)}
onChange={(ev) => setMessage(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
@ -328,16 +373,20 @@ const PublishDialog = (props) => {
fullWidth
multiline
inputProps={{
"aria-label": t("publish_dialog_message_label")
"aria-label": t("publish_dialog_message_label"),
}}
/>
<div style={{display: 'flex'}}>
<div style={{ display: "flex" }}>
<EmojiPicker
anchorEl={emojiPickerAnchorEl}
onEmojiPick={handleEmojiPick}
onClose={handleEmojiClose}
/>
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
<DialogIconButton
disabled={disabled}
onClick={handleEmojiClick}
aria-label={t("publish_dialog_emoji_picker_show")}
>
<InsertEmoticonIcon />
</DialogIconButton>
<TextField
@ -345,13 +394,13 @@ const PublishDialog = (props) => {
label={t("publish_dialog_tags_label")}
placeholder={t("publish_dialog_tags_placeholder")}
value={tags}
onChange={ev => setTags(ev.target.value)}
onChange={(ev) => setTags(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
sx={{ flexGrow: 1, marginRight: 1 }}
inputProps={{
"aria-label": t("publish_dialog_tags_label")
"aria-label": t("publish_dialog_tags_label"),
}}
/>
<FormControl
@ -367,72 +416,92 @@ const PublishDialog = (props) => {
onChange={(ev) => setPriority(ev.target.value)}
disabled={disabled}
inputProps={{
"aria-label": t("publish_dialog_priority_label")
"aria-label": t("publish_dialog_priority_label"),
}}
>
{[5,4,3,2,1].map(priority =>
<MenuItem key={`priorityMenuItem${priority}`} value={priority} aria-label={t("notifications_priority_x", { priority: priority })}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<img src={priorities[priority].file} style={{marginRight: "8px"}} alt={t("notifications_priority_x", { priority: priority })}/>
{[5, 4, 3, 2, 1].map((priority) => (
<MenuItem
key={`priorityMenuItem${priority}`}
value={priority}
aria-label={t("notifications_priority_x", {
priority: priority,
})}
>
<div style={{ display: "flex", alignItems: "center" }}>
<img
src={priorities[priority].file}
style={{ marginRight: "8px" }}
alt={t("notifications_priority_x", {
priority: priority,
})}
/>
<div>{priorities[priority].label}</div>
</div>
</MenuItem>
)}
))}
</Select>
</FormControl>
</div>
{showClickUrl &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_click_reset")} onClose={() => {
{showClickUrl && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_click_reset")}
onClose={() => {
setClickUrl("");
setShowClickUrl(false);
}}>
}}
>
<TextField
margin="dense"
label={t("publish_dialog_click_label")}
placeholder={t("publish_dialog_click_placeholder")}
value={clickUrl}
onChange={ev => setClickUrl(ev.target.value)}
onChange={(ev) => setClickUrl(ev.target.value)}
disabled={disabled}
type="url"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("publish_dialog_click_label")
"aria-label": t("publish_dialog_click_label"),
}}
/>
</ClosableRow>
}
{showEmail &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_email_reset")} onClose={() => {
)}
{showEmail && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_email_reset")}
onClose={() => {
setEmail("");
setShowEmail(false);
}}>
}}
>
<TextField
margin="dense"
label={t("publish_dialog_email_label")}
placeholder={t("publish_dialog_email_placeholder")}
value={email}
onChange={ev => setEmail(ev.target.value)}
onChange={(ev) => setEmail(ev.target.value)}
disabled={disabled}
type="email"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_email_label")
"aria-label": t("publish_dialog_email_label"),
}}
/>
</ClosableRow>
}
{showCall &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_call_reset")} onClose={() => {
)}
{showCall && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_call_reset")}
onClose={() => {
setCall("");
setShowCall(false);
}}>
<FormControl
fullWidth
variant="standard"
margin="dense"
}}
>
<FormControl fullWidth variant="standard" margin="dense">
<InputLabel />
<Select
label={t("publish_dialog_call_label")}
@ -441,31 +510,39 @@ const PublishDialog = (props) => {
onChange={(ev) => setCall(ev.target.value)}
disabled={disabled}
inputProps={{
"aria-label": t("publish_dialog_call_label")
"aria-label": t("publish_dialog_call_label"),
}}
>
{account?.phone_numbers?.map((phoneNumber, i) =>
<MenuItem key={`phoneNumberMenuItem${i}`} value={phoneNumber} aria-label={phoneNumber}>
{account?.phone_numbers?.map((phoneNumber, i) => (
<MenuItem
key={`phoneNumberMenuItem${i}`}
value={phoneNumber}
aria-label={phoneNumber}
>
{t("publish_dialog_call_item", { number: phoneNumber })}
</MenuItem>
)}
))}
</Select>
</FormControl>
</ClosableRow>
}
{showAttachUrl &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
)}
{showAttachUrl && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_attach_reset")}
onClose={() => {
setAttachUrl("");
setFilename("");
setFilenameEdited(false);
setShowAttachUrl(false);
}}>
}}
>
<TextField
margin="dense"
label={t("publish_dialog_attach_label")}
placeholder={t("publish_dialog_attach_placeholder")}
value={attachUrl}
onChange={ev => {
onChange={(ev) => {
const url = ev.target.value;
setAttachUrl(url);
if (!filenameEdited) {
@ -485,7 +562,7 @@ const PublishDialog = (props) => {
variant="standard"
sx={{ flexGrow: 5, marginRight: 1 }}
inputProps={{
"aria-label": t("publish_dialog_attach_label")
"aria-label": t("publish_dialog_attach_label"),
}}
/>
<TextField
@ -493,7 +570,7 @@ const PublishDialog = (props) => {
label={t("publish_dialog_filename_label")}
placeholder={t("publish_dialog_filename_placeholder")}
value={filename}
onChange={ev => {
onChange={(ev) => {
setFilename(ev.target.value);
setFilenameEdited(true);
}}
@ -502,19 +579,20 @@ const PublishDialog = (props) => {
variant="standard"
sx={{ flexGrow: 1 }}
inputProps={{
"aria-label": t("publish_dialog_filename_label")
"aria-label": t("publish_dialog_filename_label"),
}}
/>
</ClosableRow>
}
)}
<input
type="file"
ref={attachFileInput}
onChange={handleAttachFileChanged}
style={{ display: 'none' }}
style={{ display: "none" }}
aria-hidden={true}
/>
{showAttachFile && <AttachmentBox
{showAttachFile && (
<AttachmentBox
file={attachFile}
filename={filename}
disabled={disabled}
@ -525,57 +603,154 @@ const PublishDialog = (props) => {
setAttachFileError("");
setFilename("");
}}
/>}
{showDelay &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_delay_reset")} onClose={() => {
/>
)}
{showDelay && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_delay_reset")}
onClose={() => {
setDelay("");
setShowDelay(false);
}}>
}}
>
<TextField
margin="dense"
label={t("publish_dialog_delay_label")}
placeholder={t("publish_dialog_delay_placeholder", {
unixTimestamp: "1649029748",
relativeTime: "30m",
naturalLanguage: "tomorrow, 9am"
naturalLanguage: "tomorrow, 9am",
})}
value={delay}
onChange={ev => setDelay(ev.target.value)}
onChange={(ev) => setDelay(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_delay_label")
"aria-label": t("publish_dialog_delay_label"),
}}
/>
</ClosableRow>
}
)}
<Typography variant="body1" sx={{ marginTop: 2, marginBottom: 1 }}>
{t("publish_dialog_other_features")}
</Typography>
<div>
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{account?.phone_numbers?.length > 0 && !showCall && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_call_label")} aria-label={t("publish_dialog_chip_call_label")} onClick={() => { setShowCall(true); setCall(account.phone_numbers[0]); }} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showTopicUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_topic_label")} aria-label={t("publish_dialog_chip_topic_label")} onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{account && !account?.phone_numbers && <Tooltip title={t("publish_dialog_chip_call_no_verified_numbers_tooltip")}><span><Chip clickable disabled label={t("publish_dialog_chip_call_label")} aria-label={t("publish_dialog_chip_call_label")} sx={{marginRight: 1, marginBottom: 1}}/></span></Tooltip>}
{!showClickUrl && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_click_label")}
aria-label={t("publish_dialog_chip_click_label")}
onClick={() => setShowClickUrl(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showEmail && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_email_label")}
aria-label={t("publish_dialog_chip_email_label")}
onClick={() => setShowEmail(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{account?.phone_numbers?.length > 0 && !showCall && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_call_label")}
aria-label={t("publish_dialog_chip_call_label")}
onClick={() => {
setShowCall(true);
setCall(account.phone_numbers[0]);
}}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showAttachUrl && !showAttachFile && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_attach_url_label")}
aria-label={t("publish_dialog_chip_attach_url_label")}
onClick={() => setShowAttachUrl(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showAttachFile && !showAttachUrl && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_attach_file_label")}
aria-label={t("publish_dialog_chip_attach_file_label")}
onClick={() => handleAttachFileClick()}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showDelay && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_delay_label")}
aria-label={t("publish_dialog_chip_delay_label")}
onClick={() => setShowDelay(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showTopicUrl && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_topic_label")}
aria-label={t("publish_dialog_chip_topic_label")}
onClick={() => setShowTopicUrl(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{account && !account?.phone_numbers && (
<Tooltip
title={t(
"publish_dialog_chip_call_no_verified_numbers_tooltip"
)}
>
<span>
<Chip
clickable
disabled
label={t("publish_dialog_chip_call_label")}
aria-label={t("publish_dialog_chip_call_label")}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
</span>
</Tooltip>
)}
</div>
<Typography variant="body1" sx={{ marginTop: 1, marginBottom: 1 }}>
<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 &&
{activeRequest && (
<Button onClick={() => activeRequest.abort()}>
{t("publish_dialog_button_cancel_sending")}
</Button>
)}
{!activeRequest && (
<>
<FormControlLabel
label={t("publish_dialog_checkbox_publish_another")}
@ -586,13 +761,21 @@ const PublishDialog = (props) => {
checked={publishAnother}
onChange={(ev) => setPublishAnother(ev.target.checked)}
inputProps={{
"aria-label": t("publish_dialog_checkbox_publish_another")
}} />
} />
<Button onClick={props.onClose}>{t("publish_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>{t("publish_dialog_button_send")}</Button>
</>
"aria-label": t(
"publish_dialog_checkbox_publish_another"
),
}}
/>
}
/>
<Button onClick={props.onClose}>
{t("publish_dialog_button_cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>
{t("publish_dialog_button_send")}
</Button>
</>
)}
</DialogFooter>
</Dialog>
</>
@ -601,22 +784,27 @@ const PublishDialog = (props) => {
const Row = (props) => {
return (
<div style={{display: 'flex'}} role="row">
<div style={{ display: "flex" }} role="row">
{props.children}
</div>
);
};
const ClosableRow = (props) => {
const closable = (props.hasOwnProperty("closable")) ? props.closable : true;
const closable = props.hasOwnProperty("closable") ? props.closable : true;
return (
<Row>
{props.children}
{closable &&
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={props.closeLabel}>
{closable && (
<DialogIconButton
disabled={props.disabled}
onClick={props.onClose}
sx={{ marginLeft: "6px" }}
aria-label={props.closeLabel}
>
<Close />
</DialogIconButton>
}
)}
</Row>
);
};
@ -646,14 +834,16 @@ const AttachmentBox = (props) => {
<Typography variant="body1" sx={{ marginTop: 2 }}>
{t("publish_dialog_attached_file_title")}
</Typography>
<Box sx={{
display: 'flex',
alignItems: 'center',
<Box
sx={{
display: "flex",
alignItems: "center",
padding: 0.5,
borderRadius: '4px',
}}>
borderRadius: "4px",
}}
>
<AttachmentIcon type={file.type} />
<Box sx={{ marginLeft: 1, textAlign: 'left' }}>
<Box sx={{ marginLeft: 1, textAlign: "left" }}>
<ExpandingTextField
minWidth={140}
variant="body2"
@ -663,16 +853,26 @@ const AttachmentBox = (props) => {
disabled={props.disabled}
/>
<br />
<Typography variant="body2" sx={{ color: 'text.primary' }}>
<Typography variant="body2" sx={{ color: "text.primary" }}>
{formatBytes(file.size)}
{props.error &&
<Typography component="span" sx={{ color: 'error.main' }} aria-live="polite">
{" "}({props.error})
{props.error && (
<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>
@ -688,7 +888,9 @@ 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);
@ -712,15 +914,17 @@ const ExpandingTextField = (props) => {
type="text"
variant="standard"
sx={{ width: `${textWidth}px`, borderBottom: "none" }}
InputProps={{ style: { fontSize: theme.typography[props.variant].fontSize } }}
InputProps={{
style: { fontSize: theme.typography[props.variant].fontSize },
}}
inputProps={{
style: { paddingBottom: 0, paddingTop: 0 },
"aria-label": props.placeholder
"aria-label": props.placeholder,
}}
disabled={props.disabled}
/>
</>
)
);
};
const DropArea = (props) => {
@ -728,14 +932,14 @@ const DropArea = (props) => {
// This is where we could disallow certain files to be dragged in.
// For now we allow all files.
ev.dataTransfer.dropEffect = 'copy';
ev.dataTransfer.dropEffect = "copy";
ev.preventDefault();
};
return (
<Box
sx={{
position: 'absolute',
position: "absolute",
left: 0,
top: 0,
right: 0,
@ -753,35 +957,39 @@ const DropArea = (props) => {
const DropBox = () => {
const { t } = useTranslation();
return (
<Box sx={{
position: 'absolute',
<Box
sx={{
position: "absolute",
left: 0,
top: 0,
right: 0,
bottom: 0,
zIndex: 10000,
backgroundColor: "#ffffffbb"
}}>
backgroundColor: "#ffffffbb",
}}
>
<Box
sx={{
position: 'absolute',
border: '3px dashed #ccc',
borderRadius: '5px',
position: "absolute",
border: "3px dashed #ccc",
borderRadius: "5px",
left: "40px",
top: "40px",
right: "40px",
bottom: "40px",
zIndex: 10001,
display: 'flex',
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Typography variant="h5">{t("publish_dialog_drop_file_here")}</Typography>
<Typography variant="h5">
{t("publish_dialog_drop_file_here")}
</Typography>
</Box>
</Box>
);
}
};
PublishDialog.OPEN_MODE_DEFAULT = "default";
PublishDialog.OPEN_MODE_DRAG = "drag";

View File

@ -1,11 +1,11 @@
import * as React from 'react';
import {useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import * as React from "react";
import { useState } from "react";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import { Alert, FormControl, Select, useMediaQuery } from "@mui/material";
import theme from "./theme";
import { validTopic } from "../app/utils";
@ -26,15 +26,18 @@ export const ReserveAddDialog = (props) => {
const [error, setError] = useState("");
const [topic, setTopic] = useState(props.topic || "");
const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const 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) {
@ -51,24 +54,32 @@ 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>
{allowTopicEdit && <TextField
{allowTopicEdit && (
<TextField
autoFocus
margin="dense"
id="topic"
label={t("prefs_reservations_dialog_topic_label")}
aria-label={t("prefs_reservations_dialog_topic_label")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
onChange={(ev) => setTopic(ev.target.value)}
type="url"
fullWidth
variant="standard"
/>}
/>
)}
<ReserveTopicSelect
value={everyone}
onChange={setEveryone}
@ -77,7 +88,9 @@ export const ReserveAddDialog = (props) => {
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!submitButtonEnabled}>{t("common_add")}</Button>
<Button onClick={handleSubmit} disabled={!submitButtonEnabled}>
{t("common_add")}
</Button>
</DialogFooter>
</Dialog>
);
@ -86,13 +99,17 @@ 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 fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
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) {
@ -106,7 +123,13 @@ 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>
@ -130,12 +153,14 @@ export const ReserveDeleteDialog = (props) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [deleteMessages, setDeleteMessages] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSubmit = async () => {
try {
await accountApi.deleteReservation(props.topic, deleteMessages);
console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`);
console.debug(
`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`
);
} catch (e) {
console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e);
if (e instanceof UnauthorizedError) {
@ -149,7 +174,13 @@ 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>
@ -161,39 +192,48 @@ export const ReserveDeleteDialog = (props) => {
onChange={(ev) => setDeleteMessages(ev.target.value)}
sx={{
"& .MuiSelect-select": {
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
paddingTop: "4px",
paddingBottom: "4px",
}
},
}}
>
<MenuItem value={false}>
<ListItemIcon><Check/></ListItemIcon>
<ListItemText primary={t("reservation_delete_dialog_action_keep_title")}/>
<ListItemIcon>
<Check />
</ListItemIcon>
<ListItemText
primary={t("reservation_delete_dialog_action_keep_title")}
/>
</MenuItem>
<MenuItem value={true}>
<ListItemIcon><DeleteForever/></ListItemIcon>
<ListItemText primary={t("reservation_delete_dialog_action_delete_title")}/>
<ListItemIcon>
<DeleteForever />
</ListItemIcon>
<ListItemText
primary={t("reservation_delete_dialog_action_delete_title")}
/>
</MenuItem>
</Select>
</FormControl>
{!deleteMessages &&
{!deleteMessages && (
<Alert severity="info" sx={{ mt: 1 }}>
{t("reservation_delete_dialog_action_keep_description")}
</Alert>
}
{deleteMessages &&
)}
{deleteMessages && (
<Alert severity="warning" sx={{ mt: 1 }}>
{t("reservation_delete_dialog_action_delete_description")}
</Alert>
}
)}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} color="error">{t("reservation_delete_dialog_submit_button")}</Button>
<Button onClick={handleSubmit} color="error">
{t("reservation_delete_dialog_submit_button")}
</Button>
</DialogFooter>
</Dialog>
);
};

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import * as React from 'react';
import {useState} from 'react';
import * as React from "react";
import { useState } from "react";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
@ -31,14 +31,16 @@ 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) {
console.log(`[Signup] Signup for user ${user.username} failed`, e);
if (e instanceof UserExistsError) {
setError(t("signup_error_username_taken", { username: e.username }));
} else if ((e instanceof AccountCreateLimitReachedError)) {
} else if (e instanceof AccountCreateLimitReachedError) {
setError(t("signup_error_creation_limit_reached"));
} else {
setError(e.message);
@ -49,17 +51,22 @@ 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>
);
}
return (
<AvatarBox>
<Typography sx={{ typography: 'h6' }}>
{t("signup_title")}
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}>
<Typography sx={{ typography: "h6" }}>{t("signup_title")}</Typography>
<Box
component="form"
onSubmit={handleSubmit}
noValidate
sx={{ mt: 1, maxWidth: 400 }}
>
<TextField
margin="dense"
required
@ -68,7 +75,7 @@ const Signup = () => {
label={t("signup_form_username")}
name="username"
value={username}
onChange={ev => setUsername(ev.target.value.trim())}
onChange={(ev) => setUsername(ev.target.value.trim())}
autoFocus
/>
<TextField
@ -81,7 +88,7 @@ const Signup = () => {
id="password"
autoComplete="new-password"
value={password}
onChange={ev => setPassword(ev.target.value.trim())}
onChange={(ev) => setPassword(ev.target.value.trim())}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@ -94,7 +101,7 @@ const Signup = () => {
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
),
}}
/>
<TextField
@ -107,7 +114,7 @@ const Signup = () => {
id="confirm"
autoComplete="new-password"
value={confirm}
onChange={ev => setConfirm(ev.target.value.trim())}
onChange={(ev) => setConfirm(ev.target.value.trim())}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@ -120,7 +127,7 @@ const Signup = () => {
{showConfirm ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
),
}}
/>
<Button
@ -132,27 +139,29 @@ const Signup = () => {
>
{t("signup_form_button_submit")}
</Button>
{error &&
<Box sx={{
{error && (
<Box
sx={{
mb: 1,
display: 'flex',
display: "flex",
flexGrow: 1,
justifyContent: 'center',
}}>
justifyContent: "center",
}}
>
<WarningAmberIcon color="error" sx={{ mr: 1 }} />
<Typography sx={{color: 'error.main'}}>{error}</Typography>
<Typography sx={{ color: "error.main" }}>{error}</Typography>
</Box>
}
)}
</Box>
{config.enable_login &&
{config.enable_login && (
<Typography sx={{ mb: 4 }}>
<NavLink to={routes.login} variant="body1">
{t("signup_already_have_account")}
</NavLink>
</Typography>
}
)}
</AvatarBox>
);
}
};
export default Signup;

View File

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

View File

@ -1,12 +1,18 @@
import * as React from 'react';
import {useContext, useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import {Chip, InputAdornment, Portal, Snackbar, useMediaQuery} from "@mui/material";
import * as React from "react";
import { useContext, useState } from "react";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import {
Chip,
InputAdornment,
Portal,
Snackbar,
useMediaQuery,
} from "@mui/material";
import theme from "./theme";
import subscriptionManager from "../app/SubscriptionManager";
import DialogFooter from "./DialogFooter";
@ -22,7 +28,11 @@ 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) => {
@ -38,34 +48,58 @@ 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 showReservationAddDisabled = !showReservationAdd && config.enable_reservations && !subscription?.reservation && (config.enable_payments || account?.stats.reservations_remaining === 0);
const showReservationEdit = config.enable_reservations && !!subscription?.reservation;
const showReservationDelete = config.enable_reservations && !!subscription?.reservation;
const showReservationAdd =
config.enable_reservations &&
!subscription?.reservation &&
account?.stats.reservations_remaining > 0;
const showReservationAddDisabled =
!showReservationAdd &&
config.enable_reservations &&
!subscription?.reservation &&
(config.enable_payments || account?.stats.reservations_remaining === 0);
const showReservationEdit =
config.enable_reservations && !!subscription?.reservation;
const showReservationDelete =
config.enable_reservations && !!subscription?.reservation;
const handleChangeDisplayName = async () => {
setDisplayNameDialogOpen(true);
}
};
const handleReserveAdd = async () => {
setReserveAddDialogOpen(true);
}
};
const handleReserveEdit = async () => {
setReserveEditDialogOpen(true);
}
};
const handleReserveDelete = async () => {
setReserveDeleteDialogOpen(true);
}
};
const handleSendTestMessage = async () => {
const baseUrl = props.subscription.baseUrl;
const topic = props.subscription.topic;
const tags = shuffle([
"grinning", "octopus", "upside_down_face", "palm_tree", "maple_leaf", "apple", "skull", "warning", "jack_o_lantern",
"de-server-1", "backups", "cron-script", "script-error", "phils-automation", "mouse", "go-rocks", "hi-ben"])
.slice(0, Math.round(Math.random() * 4));
"grinning",
"octopus",
"upside_down_face",
"palm_tree",
"maple_leaf",
"apple",
"skull",
"warning",
"jack_o_lantern",
"de-server-1",
"backups",
"cron-script",
"script-error",
"phils-automation",
"mouse",
"go-rocks",
"hi-ben",
]).slice(0, Math.round(Math.random() * 4));
const priority = shuffle([1, 2, 3, 4, 5])[0];
const title = shuffle([
"",
@ -77,41 +111,53 @@ export const SubscriptionPopup = (props) => {
"I don't really like apples",
"My favorite TV show is The Wire. You should watch it!",
"You can attach files and URLs to messages too",
"You can delay messages up to 3 days"
"You can delay messages up to 3 days",
])[0];
const nowSeconds = Math.round(Date.now() / 1000);
const message = shuffle([
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
`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?`
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`,
])[0];
try {
await api.publish(baseUrl, topic, message, {
title: title,
priority: priority,
tags: tags
tags: tags,
});
} catch (e) {
console.log(`[SubscriptionPopup] Error publishing message`, e);
setShowPublishError(true);
}
}
};
const handleClearAll = async () => {
console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`);
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) {
@ -135,19 +181,41 @@ export const SubscriptionPopup = (props) => {
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 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
@ -161,29 +229,29 @@ export const SubscriptionPopup = (props) => {
subscription={subscription}
onClose={() => setDisplayNameDialogOpen(false)}
/>
{showReservationAdd &&
{showReservationAdd && (
<ReserveAddDialog
open={reserveAddDialogOpen}
topic={subscription.topic}
reservations={reservations}
onClose={() => setReserveAddDialogOpen(false)}
/>
}
{showReservationEdit &&
)}
{showReservationEdit && (
<ReserveEditDialog
open={reserveEditDialogOpen}
reservation={subscription.reservation}
reservations={props.reservations}
onClose={() => setReserveEditDialogOpen(false)}
/>
}
{showReservationDelete &&
)}
{showReservationDelete && (
<ReserveDeleteDialog
open={reserveDeleteDialogOpen}
topic={subscription.topic}
onClose={() => setReserveDeleteDialogOpen(false)}
/>
}
)}
</Portal>
</>
);
@ -193,17 +261,28 @@ const DisplayNameDialog = (props) => {
const { t } = useTranslation();
const subscription = props.subscription;
const [error, setError] = useState("");
const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const [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 {
@ -213,10 +292,16 @@ const DisplayNameDialog = (props) => {
}
}
props.onClose();
}
};
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>
@ -226,13 +311,13 @@ const DisplayNameDialog = (props) => {
autoFocus
placeholder={t("display_name_dialog_placeholder")}
value={displayName}
onChange={ev => setDisplayName(ev.target.value)}
onChange={(ev) => setDisplayName(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
maxLength: 64,
"aria-label": t("display_name_dialog_placeholder")
"aria-label": t("display_name_dialog_placeholder"),
}}
InputProps={{
endAdornment: (
@ -241,7 +326,7 @@ const DisplayNameDialog = (props) => {
<Clear />
</IconButton>
</InputAdornment>
)
),
}}
/>
</DialogContent>
@ -255,10 +340,17 @@ 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 />;
}
@ -272,7 +364,12 @@ const LimitReachedChip = () => {
label={t("action_bar_reservation_limit_reached")}
variant="outlined"
color="primary"
sx={{ opacity: 0.8, borderWidth: "2px", height: "24px", marginLeft: "5px" }}
sx={{
opacity: 0.8,
borderWidth: "2px",
height: "24px",
marginLeft: "5px",
}}
/>
);
};
@ -284,9 +381,13 @@ export const ProChip = () => {
label={"ntfy Pro"}
variant="outlined"
color="primary"
sx={{ opacity: 0.8, fontWeight: "bold", borderWidth: "2px", height: "24px", marginLeft: "5px" }}
sx={{
opacity: 0.8,
fontWeight: "bold",
borderWidth: "2px",
height: "24px",
marginLeft: "5px",
}}
/>
);
};

View File

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

View File

@ -20,7 +20,8 @@ export const useConnectionListeners = (account, subscriptions, users) => {
const navigate = useNavigate();
// Register listeners for incoming messages, and connection state changes
useEffect(() => {
useEffect(
() => {
const handleMessage = async (subscriptionId, message) => {
const subscription = await subscriptionManager.get(subscriptionId);
if (subscription.internal) {
@ -31,25 +32,41 @@ 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);
@ -57,7 +74,7 @@ export const useConnectionListeners = (account, subscriptions, users) => {
return () => {
connectionManager.resetStateListener();
connectionManager.resetMessageListener();
}
};
},
// We have to disable dep checking for "navigate". This is fine, it never changes.
// eslint-disable-next-line
@ -92,12 +109,20 @@ 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);
@ -125,7 +150,7 @@ export const useBackgroundProcesses = () => {
pruner.startWorker();
accountApi.startWorker();
}, []);
}
};
export const useAccountListener = (setAccount) => {
useEffect(() => {
@ -133,6 +158,6 @@ export const useAccountListener = (setAccount) => {
accountApi.sync(); // Dangle
return () => {
accountApi.resetListener();
}
};
}, []);
}
};

View File

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

View File

@ -14,7 +14,7 @@ const routes = {
return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;
}
return `/${subscription.topic}`;
}
},
};
export default routes;

View File

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

View File

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

View File

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