Run prettier

This commit is contained in:
nimbleghost 2023-05-23 21:13:01 +02:00
parent 206ea312bf
commit 6f6a2d1f69
49 changed files with 22902 additions and 6633 deletions

View file

@ -1,429 +1,442 @@
import {
accountBillingPortalUrl,
accountBillingSubscriptionUrl,
accountPasswordUrl,
accountPhoneUrl,
accountPhoneVerifyUrl,
accountReservationSingleUrl,
accountReservationUrl,
accountSettingsUrl,
accountSubscriptionUrl,
accountTokenUrl,
accountUrl,
maybeWithBearerAuth,
tiersUrl,
withBasicAuth,
withBearerAuth
accountBillingPortalUrl,
accountBillingSubscriptionUrl,
accountPasswordUrl,
accountPhoneUrl,
accountPhoneVerifyUrl,
accountReservationSingleUrl,
accountReservationUrl,
accountSettingsUrl,
accountSubscriptionUrl,
accountTokenUrl,
accountUrl,
maybeWithBearerAuth,
tiersUrl,
withBasicAuth,
withBearerAuth,
} from "./utils";
import session from "./Session";
import subscriptionManager from "./SubscriptionManager";
import i18n from "i18next";
import prefs from "./Prefs";
import routes from "../components/routes";
import {fetchOrThrow, UnauthorizedError} from "./errors";
import { fetchOrThrow, UnauthorizedError } from "./errors";
const delayMillis = 45000; // 45 seconds
const intervalMillis = 900000; // 15 minutes
class AccountApi {
constructor() {
this.timer = null;
this.listener = null; // Fired when account is fetched from remote
this.tiers = null; // Cached
}
constructor() {
this.timer = null;
this.listener = null; // Fired when account is fetched from remote
this.tiers = null; // Cached
}
registerListener(listener) {
this.listener = listener;
}
registerListener(listener) {
this.listener = listener;
}
resetListener() {
this.listener = null;
}
resetListener() {
this.listener = null;
}
async login(user) {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Checking auth for ${url}`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBasicAuth({}, user.username, user.password)
});
const json = await response.json(); // May throw SyntaxError
if (!json.token) {
throw new Error(`Unexpected server response: Cannot find token`);
async login(user) {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Checking auth for ${url}`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBasicAuth({}, user.username, user.password),
});
const json = await response.json(); // May throw SyntaxError
if (!json.token) {
throw new Error(`Unexpected server response: Cannot find token`);
}
return json.token;
}
async logout() {
const url = accountTokenUrl(config.base_url);
console.log(
`[AccountApi] Logging out from ${url} using token ${session.token()}`
);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
});
}
async create(username, password) {
const url = accountUrl(config.base_url);
const body = JSON.stringify({
username: username,
password: password,
});
console.log(`[AccountApi] Creating user account ${url}`);
await fetchOrThrow(url, {
method: "POST",
body: body,
});
}
async get() {
const url = accountUrl(config.base_url);
console.log(`[AccountApi] Fetching user account ${url}`);
const response = await fetchOrThrow(url, {
headers: maybeWithBearerAuth({}, session.token()), // GET /v1/account endpoint can be called by anonymous
});
const account = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Account`, account);
if (this.listener) {
this.listener(account);
}
return account;
}
async delete(password) {
const url = accountUrl(config.base_url);
console.log(`[AccountApi] Deleting user account ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
password: password,
}),
});
}
async changePassword(currentPassword, newPassword) {
const url = accountPasswordUrl(config.base_url);
console.log(`[AccountApi] Changing account password ${url}`);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
password: currentPassword,
new_password: newPassword,
}),
});
}
async createToken(label, expires) {
const url = accountTokenUrl(config.base_url);
const body = {
label: label,
expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0,
};
console.log(`[AccountApi] Creating user access token ${url}`);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify(body),
});
}
async updateToken(token, label, expires) {
const url = accountTokenUrl(config.base_url);
const body = {
token: token,
label: label,
};
if (expires > 0) {
body.expires = Math.floor(Date.now() / 1000) + expires;
}
console.log(`[AccountApi] Creating user access token ${url}`);
await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify(body),
});
}
async extendToken() {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Extending user access token ${url}`);
await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
});
}
async deleteToken(token) {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Deleting user access token ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({ "X-Token": token }, session.token()),
});
}
async updateSettings(payload) {
const url = accountSettingsUrl(config.base_url);
const body = JSON.stringify(payload);
console.log(`[AccountApi] Updating user account ${url}: ${body}`);
await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: body,
});
}
async addSubscription(baseUrl, topic) {
const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify({
base_url: baseUrl,
topic: topic,
});
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: body,
});
const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription);
return subscription;
}
async updateSubscription(baseUrl, topic, payload) {
const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify({
base_url: baseUrl,
topic: topic,
...payload,
});
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: body,
});
const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription);
return subscription;
}
async deleteSubscription(baseUrl, topic) {
const url = accountSubscriptionUrl(config.base_url);
console.log(`[AccountApi] Removing user subscription ${url}`);
const headers = {
"X-BaseURL": baseUrl,
"X-Topic": topic,
};
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth(headers, session.token()),
});
}
async upsertReservation(topic, everyone) {
const url = accountReservationUrl(config.base_url);
console.log(
`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`
);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
topic: topic,
everyone: everyone,
}),
});
}
async deleteReservation(topic, deleteMessages) {
const url = accountReservationSingleUrl(config.base_url, topic);
console.log(`[AccountApi] Removing topic reservation ${url}`);
const headers = {
"X-Delete-Messages": deleteMessages ? "true" : "false",
};
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth(headers, session.token()),
});
}
async billingTiers() {
if (this.tiers) {
return this.tiers;
}
const url = tiersUrl(config.base_url);
console.log(`[AccountApi] Fetching billing tiers`);
const response = await fetchOrThrow(url); // No auth needed!
this.tiers = await response.json(); // May throw SyntaxError
return this.tiers;
}
async createBillingSubscription(tier, interval) {
console.log(
`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`
);
return await this.upsertBillingSubscription("POST", tier, interval);
}
async updateBillingSubscription(tier, interval) {
console.log(
`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`
);
return await this.upsertBillingSubscription("PUT", tier, interval);
}
async upsertBillingSubscription(method, tier, interval) {
const url = accountBillingSubscriptionUrl(config.base_url);
const response = await fetchOrThrow(url, {
method: method,
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
tier: tier,
interval: interval,
}),
});
return await response.json(); // May throw SyntaxError
}
async deleteBillingSubscription() {
const url = accountBillingSubscriptionUrl(config.base_url);
console.log(`[AccountApi] Cancelling billing subscription`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
});
}
async createBillingPortalSession() {
const url = accountBillingPortalUrl(config.base_url);
console.log(`[AccountApi] Creating billing portal session`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
});
return await response.json(); // May throw SyntaxError
}
async verifyPhoneNumber(phoneNumber, channel) {
const url = accountPhoneVerifyUrl(config.base_url);
console.log(`[AccountApi] Sending phone verification ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
channel: channel,
}),
});
}
async addPhoneNumber(phoneNumber, code) {
const url = accountPhoneUrl(config.base_url);
console.log(
`[AccountApi] Adding phone number with verification code ${url}`
);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
code: code,
}),
});
}
async deletePhoneNumber(phoneNumber, code) {
const url = accountPhoneUrl(config.base_url);
console.log(`[AccountApi] Deleting phone number ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
}),
});
}
async sync() {
try {
if (!session.token()) {
return null;
}
console.log(`[AccountApi] Syncing account`);
const account = await this.get();
if (account.language) {
await i18n.changeLanguage(account.language);
}
if (account.notification) {
if (account.notification.sound) {
await prefs.setSound(account.notification.sound);
}
return json.token;
}
async logout() {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token())
});
}
async create(username, password) {
const url = accountUrl(config.base_url);
const body = JSON.stringify({
username: username,
password: password
});
console.log(`[AccountApi] Creating user account ${url}`);
await fetchOrThrow(url, {
method: "POST",
body: body
});
}
async get() {
const url = accountUrl(config.base_url);
console.log(`[AccountApi] Fetching user account ${url}`);
const response = await fetchOrThrow(url, {
headers: maybeWithBearerAuth({}, session.token()) // GET /v1/account endpoint can be called by anonymous
});
const account = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Account`, account);
if (this.listener) {
this.listener(account);
if (account.notification.delete_after) {
await prefs.setDeleteAfter(account.notification.delete_after);
}
return account;
}
async delete(password) {
const url = accountUrl(config.base_url);
console.log(`[AccountApi] Deleting user account ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
password: password
})
});
}
async changePassword(currentPassword, newPassword) {
const url = accountPasswordUrl(config.base_url);
console.log(`[AccountApi] Changing account password ${url}`);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
password: currentPassword,
new_password: newPassword
})
});
}
async createToken(label, expires) {
const url = accountTokenUrl(config.base_url);
const body = {
label: label,
expires: (expires > 0) ? Math.floor(Date.now() / 1000) + expires : 0
};
console.log(`[AccountApi] Creating user access token ${url}`);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify(body)
});
}
async updateToken(token, label, expires) {
const url = accountTokenUrl(config.base_url);
const body = {
token: token,
label: label
};
if (expires > 0) {
body.expires = Math.floor(Date.now() / 1000) + expires;
if (account.notification.min_priority) {
await prefs.setMinPriority(account.notification.min_priority);
}
console.log(`[AccountApi] Creating user access token ${url}`);
await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify(body)
});
}
if (account.subscriptions) {
await subscriptionManager.syncFromRemote(
account.subscriptions,
account.reservations
);
}
return account;
} catch (e) {
console.log(`[AccountApi] Error fetching account`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
async extendToken() {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Extending user access token ${url}`);
await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token())
});
startWorker() {
if (this.timer !== null) {
return;
}
console.log(`[AccountApi] Starting worker`);
this.timer = setInterval(() => this.runWorker(), intervalMillis);
setTimeout(() => this.runWorker(), delayMillis);
}
async deleteToken(token) {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Deleting user access token ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({"X-Token": token}, session.token())
});
async runWorker() {
if (!session.token()) {
return;
}
async updateSettings(payload) {
const url = accountSettingsUrl(config.base_url);
const body = JSON.stringify(payload);
console.log(`[AccountApi] Updating user account ${url}: ${body}`);
await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: body
});
}
async addSubscription(baseUrl, topic) {
const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify({
base_url: baseUrl,
topic: topic
});
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: body
});
const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription);
return subscription;
}
async updateSubscription(baseUrl, topic, payload) {
const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify({
base_url: baseUrl,
topic: topic,
...payload
});
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: body
});
const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription);
return subscription;
}
async deleteSubscription(baseUrl, topic) {
const url = accountSubscriptionUrl(config.base_url);
console.log(`[AccountApi] Removing user subscription ${url}`);
const headers = {
"X-BaseURL": baseUrl,
"X-Topic": topic,
}
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth(headers, session.token()),
});
}
async upsertReservation(topic, everyone) {
const url = accountReservationUrl(config.base_url);
console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
topic: topic,
everyone: everyone
})
});
}
async deleteReservation(topic, deleteMessages) {
const url = accountReservationSingleUrl(config.base_url, topic);
console.log(`[AccountApi] Removing topic reservation ${url}`);
const headers = {
"X-Delete-Messages": deleteMessages ? "true" : "false"
}
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth(headers, session.token())
});
}
async billingTiers() {
if (this.tiers) {
return this.tiers;
}
const url = tiersUrl(config.base_url);
console.log(`[AccountApi] Fetching billing tiers`);
const response = await fetchOrThrow(url); // No auth needed!
this.tiers = await response.json(); // May throw SyntaxError
return this.tiers;
}
async createBillingSubscription(tier, interval) {
console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`);
return await this.upsertBillingSubscription("POST", tier, interval)
}
async updateBillingSubscription(tier, interval) {
console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`);
return await this.upsertBillingSubscription("PUT", tier, interval)
}
async upsertBillingSubscription(method, tier, interval) {
const url = accountBillingSubscriptionUrl(config.base_url);
const response = await fetchOrThrow(url, {
method: method,
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
tier: tier,
interval: interval
})
});
return await response.json(); // May throw SyntaxError
}
async deleteBillingSubscription() {
const url = accountBillingSubscriptionUrl(config.base_url);
console.log(`[AccountApi] Cancelling billing subscription`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token())
});
}
async createBillingPortalSession() {
const url = accountBillingPortalUrl(config.base_url);
console.log(`[AccountApi] Creating billing portal session`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token())
});
return await response.json(); // May throw SyntaxError
}
async verifyPhoneNumber(phoneNumber, channel) {
const url = accountPhoneVerifyUrl(config.base_url);
console.log(`[AccountApi] Sending phone verification ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
channel: channel
})
});
}
async addPhoneNumber(phoneNumber, code) {
const url = accountPhoneUrl(config.base_url);
console.log(`[AccountApi] Adding phone number with verification code ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
code: code
})
});
}
async deletePhoneNumber(phoneNumber, code) {
const url = accountPhoneUrl(config.base_url);
console.log(`[AccountApi] Deleting phone number ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber
})
});
}
async sync() {
try {
if (!session.token()) {
return null;
}
console.log(`[AccountApi] Syncing account`);
const account = await this.get();
if (account.language) {
await i18n.changeLanguage(account.language);
}
if (account.notification) {
if (account.notification.sound) {
await prefs.setSound(account.notification.sound);
}
if (account.notification.delete_after) {
await prefs.setDeleteAfter(account.notification.delete_after);
}
if (account.notification.min_priority) {
await prefs.setMinPriority(account.notification.min_priority);
}
}
if (account.subscriptions) {
await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations);
}
return account;
} catch (e) {
console.log(`[AccountApi] Error fetching account`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
startWorker() {
if (this.timer !== null) {
return;
}
console.log(`[AccountApi] Starting worker`);
this.timer = setInterval(() => this.runWorker(), intervalMillis);
setTimeout(() => this.runWorker(), delayMillis);
}
async runWorker() {
if (!session.token()) {
return;
}
console.log(`[AccountApi] Extending user access token`);
try {
await this.extendToken();
} catch (e) {
console.log(`[AccountApi] Error extending user access token`, e);
}
console.log(`[AccountApi] Extending user access token`);
try {
await this.extendToken();
} catch (e) {
console.log(`[AccountApi] Error extending user access token`, e);
}
}
}
// Maps to user.Role in user/types.go
export const Role = {
ADMIN: "admin",
USER: "user"
ADMIN: "admin",
USER: "user",
};
// Maps to server.visitorLimitBasis in server/visitor.go
export const LimitBasis = {
IP: "ip",
TIER: "tier"
IP: "ip",
TIER: "tier",
};
// Maps to stripe.SubscriptionStatus
export const SubscriptionStatus = {
ACTIVE: "active",
PAST_DUE: "past_due"
ACTIVE: "active",
PAST_DUE: "past_due",
};
// Maps to stripe.PriceRecurringInterval
export const SubscriptionInterval = {
MONTH: "month",
YEAR: "year"
MONTH: "month",
YEAR: "year",
};
// Maps to user.Permission in user/types.go
export const Permission = {
READ_WRITE: "read-write",
READ_ONLY: "read-only",
WRITE_ONLY: "write-only",
DENY_ALL: "deny-all"
READ_WRITE: "read-write",
READ_ONLY: "read-only",
WRITE_ONLY: "write-only",
DENY_ALL: "deny-all",
};
const accountApi = new AccountApi();

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import Connection from "./Connection";
import {hashCode} from "./utils";
import { hashCode } from "./utils";
/**
* The connection manager keeps track of active connections (WebSocket connections, see Connection).
@ -8,110 +8,130 @@ import {hashCode} from "./utils";
* as required. This is done pretty much exactly the same way as in the Android app.
*/
class ConnectionManager {
constructor() {
this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
this.stateListener = null; // Fired when connection state changes
this.messageListener = null; // Fired when new notifications arrive
constructor() {
this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
this.stateListener = null; // Fired when connection state changes
this.messageListener = null; // Fired when new notifications arrive
}
registerStateListener(listener) {
this.stateListener = listener;
}
resetStateListener() {
this.stateListener = null;
}
registerMessageListener(listener) {
this.messageListener = listener;
}
resetMessageListener() {
this.messageListener = null;
}
/**
* This function figures out which websocket connections should be running by comparing the
* current state of the world (connections) with the target state (targetIds).
*
* It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify
* connections. If any of them change, the connection is closed/replaced.
*/
async refresh(subscriptions, users) {
if (!subscriptions || !users) {
return;
}
console.log(`[ConnectionManager] Refreshing connections`);
const subscriptionsWithUsersAndConnectionId = await Promise.all(
subscriptions.map(async (s) => {
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
const connectionId = await makeConnectionId(s, user);
return { ...s, user, connectionId };
})
);
const targetIds = subscriptionsWithUsersAndConnectionId.map(
(s) => s.connectionId
);
const deletedIds = Array.from(this.connections.keys()).filter(
(id) => !targetIds.includes(id)
);
registerStateListener(listener) {
this.stateListener = listener;
// Create and add new connections
subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
const subscriptionId = subscription.id;
const connectionId = subscription.connectionId;
const added = !this.connections.get(connectionId);
if (added) {
const baseUrl = subscription.baseUrl;
const topic = subscription.topic;
const user = subscription.user;
const since = subscription.last;
const connection = new Connection(
connectionId,
subscriptionId,
baseUrl,
topic,
user,
since,
(subscriptionId, notification) =>
this.notificationReceived(subscriptionId, notification),
(subscriptionId, state) => this.stateChanged(subscriptionId, state)
);
this.connections.set(connectionId, connection);
console.log(
`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${
user ? user.username : "anonymous"
})`
);
connection.start();
}
});
// Delete old connections
deletedIds.forEach((id) => {
console.log(`[ConnectionManager] Closing connection ${id}`);
const connection = this.connections.get(id);
this.connections.delete(id);
connection.close();
});
}
stateChanged(subscriptionId, state) {
if (this.stateListener) {
try {
this.stateListener(subscriptionId, state);
} catch (e) {
console.error(
`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`,
e
);
}
}
}
resetStateListener() {
this.stateListener = null;
}
registerMessageListener(listener) {
this.messageListener = listener;
}
resetMessageListener() {
this.messageListener = null;
}
/**
* This function figures out which websocket connections should be running by comparing the
* current state of the world (connections) with the target state (targetIds).
*
* It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify
* connections. If any of them change, the connection is closed/replaced.
*/
async refresh(subscriptions, users) {
if (!subscriptions || !users) {
return;
}
console.log(`[ConnectionManager] Refreshing connections`);
const subscriptionsWithUsersAndConnectionId = await Promise.all(subscriptions
.map(async s => {
const [user] = users.filter(u => u.baseUrl === s.baseUrl);
const connectionId = await makeConnectionId(s, user);
return {...s, user, connectionId};
}));
const targetIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId);
const deletedIds = Array.from(this.connections.keys()).filter(id => !targetIds.includes(id));
// Create and add new connections
subscriptionsWithUsersAndConnectionId.forEach(subscription => {
const subscriptionId = subscription.id;
const connectionId = subscription.connectionId;
const added = !this.connections.get(connectionId)
if (added) {
const baseUrl = subscription.baseUrl;
const topic = subscription.topic;
const user = subscription.user;
const since = subscription.last;
const connection = new Connection(
connectionId,
subscriptionId,
baseUrl,
topic,
user,
since,
(subscriptionId, notification) => this.notificationReceived(subscriptionId, notification),
(subscriptionId, state) => this.stateChanged(subscriptionId, state)
);
this.connections.set(connectionId, connection);
console.log(`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})`);
connection.start();
}
});
// Delete old connections
deletedIds.forEach(id => {
console.log(`[ConnectionManager] Closing connection ${id}`);
const connection = this.connections.get(id);
this.connections.delete(id);
connection.close();
});
}
stateChanged(subscriptionId, state) {
if (this.stateListener) {
try {
this.stateListener(subscriptionId, state);
} catch (e) {
console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e);
}
}
}
notificationReceived(subscriptionId, notification) {
if (this.messageListener) {
try {
this.messageListener(subscriptionId, notification);
} catch (e) {
console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e);
}
}
notificationReceived(subscriptionId, notification) {
if (this.messageListener) {
try {
this.messageListener(subscriptionId, notification);
} catch (e) {
console.error(
`[ConnectionManager] Error handling notification for ${subscriptionId}`,
e
);
}
}
}
}
const makeConnectionId = async (subscription, user) => {
return (user)
? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`)
: hashCode(`${subscription.id}`);
}
return user
? hashCode(
`${subscription.id}|${user.username}|${user.password ?? ""}|${
user.token ?? ""
}`
)
: hashCode(`${subscription.id}`);
};
const connectionManager = new ConnectionManager();
export default connectionManager;

View file

@ -1,4 +1,11 @@
import {formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl} from "./utils";
import {
formatMessage,
formatTitleWithDefault,
openUrl,
playSound,
topicDisplayName,
topicShortUrl,
} from "./utils";
import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
import logo from "../img/ntfy.png";
@ -8,89 +15,93 @@ import logo from "../img/ntfy.png";
* support this; most importantly, all iOS browsers do not support window.Notification.
*/
class Notifier {
async notify(subscriptionId, notification, onClickFallback) {
if (!this.supported()) {
return;
}
const subscription = await subscriptionManager.get(subscriptionId);
const shouldNotify = await this.shouldNotify(subscription, notification);
if (!shouldNotify) {
return;
}
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
const displayName = topicDisplayName(subscription);
const message = formatMessage(notification);
const title = formatTitleWithDefault(notification, displayName);
async notify(subscriptionId, notification, onClickFallback) {
if (!this.supported()) {
return;
}
const subscription = await subscriptionManager.get(subscriptionId);
const shouldNotify = await this.shouldNotify(subscription, notification);
if (!shouldNotify) {
return;
}
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
const displayName = topicDisplayName(subscription);
const message = formatMessage(notification);
const title = formatTitleWithDefault(notification, displayName);
// Show notification
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
const n = new Notification(title, {
body: message,
icon: logo
});
if (notification.click) {
n.onclick = (e) => openUrl(notification.click);
} else {
n.onclick = () => onClickFallback(subscription);
}
// Play sound
const sound = await prefs.sound();
if (sound && sound !== "none") {
try {
await playSound(sound);
} catch (e) {
console.log(`[Notifier, ${shortUrl}] Error playing audio`, e);
}
}
// Show notification
console.log(
`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`
);
const n = new Notification(title, {
body: message,
icon: logo,
});
if (notification.click) {
n.onclick = (e) => openUrl(notification.click);
} else {
n.onclick = () => onClickFallback(subscription);
}
granted() {
return this.supported() && Notification.permission === 'granted';
// Play sound
const sound = await prefs.sound();
if (sound && sound !== "none") {
try {
await playSound(sound);
} catch (e) {
console.log(`[Notifier, ${shortUrl}] Error playing audio`, e);
}
}
}
maybeRequestPermission(cb) {
if (!this.supported()) {
cb(false);
return;
}
if (!this.granted()) {
Notification.requestPermission().then((permission) => {
const granted = permission === 'granted';
cb(granted);
});
}
}
granted() {
return this.supported() && Notification.permission === "granted";
}
async shouldNotify(subscription, notification) {
if (subscription.mutedUntil === 1) {
return false;
}
const priority = (notification.priority) ? notification.priority : 3;
const minPriority = await prefs.minPriority();
if (priority < minPriority) {
return false;
}
return true;
maybeRequestPermission(cb) {
if (!this.supported()) {
cb(false);
return;
}
if (!this.granted()) {
Notification.requestPermission().then((permission) => {
const granted = permission === "granted";
cb(granted);
});
}
}
supported() {
return this.browserSupported() && this.contextSupported();
async shouldNotify(subscription, notification) {
if (subscription.mutedUntil === 1) {
return false;
}
const priority = notification.priority ? notification.priority : 3;
const minPriority = await prefs.minPriority();
if (priority < minPriority) {
return false;
}
return true;
}
browserSupported() {
return 'Notification' in window;
}
supported() {
return this.browserSupported() && this.contextSupported();
}
/**
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
*/
contextSupported() {
return location.protocol === 'https:'
|| location.hostname.match('^127.')
|| location.hostname === 'localhost';
}
browserSupported() {
return "Notification" in window;
}
/**
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
*/
contextSupported() {
return (
location.protocol === "https:" ||
location.hostname.match("^127.") ||
location.hostname === "localhost"
);
}
}
const notifier = new Notifier();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

@ -1,66 +1,80 @@
// This is a subset of, and the counterpart to errors.go
export const fetchOrThrow = async (url, options) => {
const response = await fetch(url, options);
if (response.status !== 200) {
await throwAppError(response);
}
return response; // Promise!
const response = await fetch(url, options);
if (response.status !== 200) {
await throwAppError(response);
}
return response; // Promise!
};
export const throwAppError = async (response) => {
if (response.status === 401 || response.status === 403) {
console.log(`[Error] HTTP ${response.status}`, response);
throw new UnauthorizedError();
if (response.status === 401 || response.status === 403) {
console.log(`[Error] HTTP ${response.status}`, response);
throw new UnauthorizedError();
}
const error = await maybeToJson(response);
if (error?.code) {
console.log(
`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${
error.error || ""
}`,
response
);
if (error.code === UserExistsError.CODE) {
throw new UserExistsError();
} else if (error.code === TopicReservedError.CODE) {
throw new TopicReservedError();
} else if (error.code === AccountCreateLimitReachedError.CODE) {
throw new AccountCreateLimitReachedError();
} else if (error.code === IncorrectPasswordError.CODE) {
throw new IncorrectPasswordError();
} else if (error?.error) {
throw new Error(`Error ${error.code}: ${error.error}`);
}
const error = await maybeToJson(response);
if (error?.code) {
console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response);
if (error.code === UserExistsError.CODE) {
throw new UserExistsError();
} else if (error.code === TopicReservedError.CODE) {
throw new TopicReservedError();
} else if (error.code === AccountCreateLimitReachedError.CODE) {
throw new AccountCreateLimitReachedError();
} else if (error.code === IncorrectPasswordError.CODE) {
throw new IncorrectPasswordError();
} else if (error?.error) {
throw new Error(`Error ${error.code}: ${error.error}`);
}
}
console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response);
throw new Error(`Unexpected response ${response.status}`);
}
console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response);
throw new Error(`Unexpected response ${response.status}`);
};
const maybeToJson = async (response) => {
try {
return await response.json();
} catch (e) {
return null;
}
}
try {
return await response.json();
} catch (e) {
return null;
}
};
export class UnauthorizedError extends Error {
constructor() { super("Unauthorized"); }
constructor() {
super("Unauthorized");
}
}
export class UserExistsError extends Error {
static CODE = 40901; // errHTTPConflictUserExists
constructor() { super("Username already exists"); }
static CODE = 40901; // errHTTPConflictUserExists
constructor() {
super("Username already exists");
}
}
export class TopicReservedError extends Error {
static CODE = 40902; // errHTTPConflictTopicReserved
constructor() { super("Topic already reserved"); }
static CODE = 40902; // errHTTPConflictTopicReserved
constructor() {
super("Topic already reserved");
}
}
export class AccountCreateLimitReachedError extends Error {
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
constructor() { super("Account creation limit reached"); }
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
constructor() {
super("Account creation limit reached");
}
}
export class IncorrectPasswordError extends Error {
static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation
constructor() { super("Password incorrect"); }
static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation
constructor() {
super("Password incorrect");
}
}

View file

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