Merge branch 'main' into markdown
commit
7d46f1eed9
|
@ -0,0 +1,3 @@
|
||||||
|
dist
|
||||||
|
*/node_modules
|
||||||
|
Dockerfile*
|
|
@ -5,3 +5,7 @@
|
||||||
c87549e71a10bc789eac8036078228f06e515a8e
|
c87549e71a10bc789eac8036078228f06e515a8e
|
||||||
ca5d736a7169eb6b4b0d849e061d5bf9565dcc53
|
ca5d736a7169eb6b4b0d849e061d5bf9565dcc53
|
||||||
2e27f58963feb9e4d1c573d4745d07770777fa7d
|
2e27f58963feb9e4d1c573d4745d07770777fa7d
|
||||||
|
|
||||||
|
# Run eslint (https://github.com/binwiederhier/ntfy/pull/748)
|
||||||
|
f558b4dbe9bb5b9e0e87fada1215de2558353173
|
||||||
|
8319f1cf26113167fb29fe12edaff5db74caf35f
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
FROM golang:1.19-bullseye as builder
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG COMMIT=unknown
|
||||||
|
|
||||||
|
RUN apt-get update
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash
|
||||||
|
RUN apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
nodejs \
|
||||||
|
python3-pip
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ADD Makefile .
|
||||||
|
|
||||||
|
# docs
|
||||||
|
ADD ./requirements.txt .
|
||||||
|
RUN make docs-deps
|
||||||
|
ADD ./mkdocs.yml .
|
||||||
|
ADD ./docs ./docs
|
||||||
|
RUN make docs-build
|
||||||
|
|
||||||
|
# web
|
||||||
|
ADD ./web/package.json ./web/package-lock.json ./web/
|
||||||
|
RUN make web-deps
|
||||||
|
ADD ./web ./web
|
||||||
|
RUN make web-build
|
||||||
|
|
||||||
|
# cli & server
|
||||||
|
ADD go.mod go.sum main.go ./
|
||||||
|
ADD ./client ./client
|
||||||
|
ADD ./cmd ./cmd
|
||||||
|
ADD ./log ./log
|
||||||
|
ADD ./server ./server
|
||||||
|
ADD ./user ./user
|
||||||
|
ADD ./util ./util
|
||||||
|
RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
|
||||||
|
|
||||||
|
FROM alpine
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
|
||||||
|
LABEL org.opencontainers.image.url="https://ntfy.sh/"
|
||||||
|
LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/"
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy"
|
||||||
|
LABEL org.opencontainers.image.vendor="Philipp C. Heckel"
|
||||||
|
LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
|
||||||
|
LABEL org.opencontainers.image.title="ntfy"
|
||||||
|
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy
|
||||||
|
|
||||||
|
EXPOSE 80/tcp
|
||||||
|
ENTRYPOINT ["ntfy"]
|
31
Makefile
31
Makefile
|
@ -31,12 +31,16 @@ help:
|
||||||
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
|
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
|
||||||
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
|
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
|
||||||
@echo
|
@echo
|
||||||
|
@echo "Build dev Docker:"
|
||||||
|
@echo " make docker-dev - Build client & server for current architecture using Docker only"
|
||||||
|
@echo
|
||||||
@echo "Build web app:"
|
@echo "Build web app:"
|
||||||
@echo " make web - Build the web app"
|
@echo " make web - Build the web app"
|
||||||
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||||
@echo " make web-build - Actually build the web app"
|
@echo " make web-build - Actually build the web app"
|
||||||
@echo " make web-format - Run prettier on the web app
|
@echo " make web-lint - Run eslint on the web app"
|
||||||
@echo " make web-format-check - Run prettier on the web app, but don't change anything
|
@echo " make web-format - Run prettier on the web app"
|
||||||
|
@echo " make web-format-check - Run prettier on the web app, but don't change anything"
|
||||||
@echo
|
@echo
|
||||||
@echo "Build documentation:"
|
@echo "Build documentation:"
|
||||||
@echo " make docs - Build the documentation"
|
@echo " make docs - Build the documentation"
|
||||||
|
@ -82,16 +86,25 @@ build: web docs cli
|
||||||
update: web-deps-update cli-deps-update docs-deps-update
|
update: web-deps-update cli-deps-update docs-deps-update
|
||||||
docker pull alpine
|
docker pull alpine
|
||||||
|
|
||||||
|
docker-dev:
|
||||||
|
docker build \
|
||||||
|
--file ./Dockerfile-build \
|
||||||
|
--tag binwiederhier/ntfy:$(VERSION) \
|
||||||
|
--tag binwiederhier/ntfy:dev \
|
||||||
|
--build-arg VERSION=$(VERSION) \
|
||||||
|
--build-arg COMMIT=$(COMMIT) \
|
||||||
|
./
|
||||||
|
|
||||||
# Ubuntu-specific
|
# Ubuntu-specific
|
||||||
|
|
||||||
build-deps-ubuntu:
|
build-deps-ubuntu:
|
||||||
sudo apt update
|
sudo apt-get update
|
||||||
sudo apt install -y \
|
sudo apt-get install -y \
|
||||||
curl \
|
curl \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-aarch64-linux-gnu \
|
||||||
gcc-arm-linux-gnueabi \
|
gcc-arm-linux-gnueabi \
|
||||||
jq
|
jq
|
||||||
which pip3 || sudo apt install -y python3-pip
|
which pip3 || sudo apt-get install -y python3-pip
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
|
|
||||||
|
@ -129,8 +142,7 @@ web-build:
|
||||||
&& rm -rf ../server/site \
|
&& rm -rf ../server/site \
|
||||||
&& mv build ../server/site \
|
&& mv build ../server/site \
|
||||||
&& rm \
|
&& rm \
|
||||||
../server/site/config.js \
|
../server/site/config.js
|
||||||
../server/site/asset-manifest.json
|
|
||||||
|
|
||||||
web-deps:
|
web-deps:
|
||||||
cd web && npm install
|
cd web && npm install
|
||||||
|
@ -145,6 +157,9 @@ web-format:
|
||||||
web-format-check:
|
web-format-check:
|
||||||
cd web && npm run format:check
|
cd web && npm run format:check
|
||||||
|
|
||||||
|
web-lint:
|
||||||
|
cd web && npm run lint
|
||||||
|
|
||||||
# Main server/client build
|
# Main server/client build
|
||||||
|
|
||||||
cli: cli-deps
|
cli: cli-deps
|
||||||
|
@ -233,7 +248,7 @@ cli-build-results:
|
||||||
|
|
||||||
# Test/check targets
|
# Test/check targets
|
||||||
|
|
||||||
check: test web-format-check fmt-check vet lint staticcheck
|
check: test web-format-check fmt-check vet web-lint lint staticcheck
|
||||||
|
|
||||||
test: .PHONY
|
test: .PHONY
|
||||||
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||||
|
|
|
@ -163,6 +163,15 @@ $ make release-snapshot
|
||||||
|
|
||||||
During development, you may want to be more picky and build only certain things. Here are a few examples.
|
During development, you may want to be more picky and build only certain things. Here are a few examples.
|
||||||
|
|
||||||
|
### Build a Docker image only for Linux
|
||||||
|
|
||||||
|
This is useful to test the final build with web app, docs, and server without any dependencies locally
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make docker-dev
|
||||||
|
$ docker run --rm -p 80:80 binwiederhier/ntfy:dev serve
|
||||||
|
```
|
||||||
|
|
||||||
### Build the ntfy binary
|
### Build the ntfy binary
|
||||||
To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets:
|
To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets:
|
||||||
|
|
||||||
|
|
|
@ -1222,8 +1222,13 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||||
|
|
||||||
### ntfy server v2.6.0 (UNRELEASED)
|
### ntfy server v2.6.0 (UNRELEASED)
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
**Bug fixes:**
|
||||||
|
|
||||||
* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting)
|
* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting)
|
||||||
|
|
||||||
|
**Maintenance:**
|
||||||
|
|
||||||
* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
||||||
* Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
* Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
||||||
|
* Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
||||||
|
* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
src/app/emojis.js
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"extends": ["airbnb", "prettier"],
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"globals": {
|
||||||
|
"config": "readonly"
|
||||||
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2023
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-console": "off",
|
||||||
|
"class-methods-use-this": "off",
|
||||||
|
"func-style": ["error", "expression"],
|
||||||
|
"no-restricted-syntax": ["error", "ForInStatement", "LabeledStatement", "WithStatement"],
|
||||||
|
"no-await-in-loop": "error",
|
||||||
|
"import/no-cycle": "warn",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"react/destructuring-assignment": "off",
|
||||||
|
"react/jsx-no-useless-fragment": "off",
|
||||||
|
"react/jsx-props-no-spreading": "off",
|
||||||
|
"react/jsx-no-duplicate-props": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"ignoreCase": false // For <TextField>'s [iI]nputProps
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/function-component-definition": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"namedComponents": "arrow-function",
|
||||||
|
"unnamedComponents": "arrow-function"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
public/static/langs/
|
public/static/langs/
|
||||||
|
src/app/emojis.js
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
|
||||||
|
|
||||||
<!-- Favicon, see favicon.io -->
|
<!-- Favicon, see favicon.io -->
|
||||||
<link rel="icon" type="image/png" href="%PUBLIC_URL%/static/images/favicon.ico" />
|
<link rel="icon" type="image/png" href="/static/images/favicon.ico" />
|
||||||
|
|
||||||
<!-- Previews in Google, Slack, WhatsApp, etc. -->
|
<!-- Previews in Google, Slack, WhatsApp, etc. -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
|
@ -26,15 +26,15 @@
|
||||||
property="og:description"
|
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."
|
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:image" content="/static/images/ntfy.png" />
|
||||||
<meta property="og:url" content="https://ntfy.sh" />
|
<meta property="og:url" content="https://ntfy.sh" />
|
||||||
|
|
||||||
<!-- Never index -->
|
<!-- Never index -->
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
|
||||||
<!-- Style overrides & fonts -->
|
<!-- Style overrides & fonts -->
|
||||||
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/app.css" type="text/css" />
|
<link rel="stylesheet" href="/static/css/app.css" type="text/css" />
|
||||||
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css" />
|
<link rel="stylesheet" href="/static/css/fonts.css" type="text/css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<noscript>
|
||||||
|
@ -43,6 +43,7 @@
|
||||||
subscribe.
|
subscribe.
|
||||||
</noscript>
|
</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script src="%PUBLIC_URL%/config.js"></script>
|
<script src="/config.js"></script>
|
||||||
|
<script type="module" src="/src/index.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
File diff suppressed because it is too large
Load Diff
|
@ -3,14 +3,16 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "NODE_OPTIONS=\"--enable-source-maps\" vite",
|
||||||
"build": "react-scripts build",
|
"build": "vite build",
|
||||||
"test": "react-scripts test",
|
"serve": "vite preview",
|
||||||
"eject": "react-scripts eject",
|
|
||||||
"format": "prettier . --write",
|
"format": "prettier . --write",
|
||||||
"format:check": "prettier . --check"
|
"format:check": "prettier . --check",
|
||||||
|
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.11.0",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/icons-material": "^5.4.2",
|
"@mui/icons-material": "^5.4.2",
|
||||||
"@mui/material": "latest",
|
"@mui/material": "latest",
|
||||||
"dexie": "^3.2.1",
|
"dexie": "^3.2.1",
|
||||||
|
@ -29,8 +31,16 @@
|
||||||
"stacktrace-js": "^2.0.2"
|
"stacktrace-js": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
"eslint": "^8.41.0",
|
||||||
|
"eslint-config-airbnb": "^19.0.4",
|
||||||
|
"eslint-config-prettier": "^8.8.0",
|
||||||
|
"eslint-plugin-import": "^2.27.5",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||||
|
"eslint-plugin-react": "^7.32.2",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
"react-scripts": "^5.0.0"
|
"vite": "^4.3.8"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|
|
@ -355,5 +355,15 @@
|
||||||
"account_upgrade_dialog_billing_contact_website": "Otázky týkající se fakturace naleznete na našich <Link>webových stránkách</Link>.",
|
"account_upgrade_dialog_billing_contact_website": "Otázky týkající se fakturace naleznete na našich <Link>webových stránkách</Link>.",
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezervované téma",
|
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezervované téma",
|
||||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} denní zpráva",
|
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} denní zpráva",
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} denní e-mail"
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} denní e-mail",
|
||||||
|
"publish_dialog_call_label": "Telefonát",
|
||||||
|
"publish_dialog_call_reset": "Odstranit telefonát",
|
||||||
|
"publish_dialog_chip_call_label": "Telefonát",
|
||||||
|
"account_basics_phone_numbers_title": "Telefonní čísla",
|
||||||
|
"account_basics_phone_numbers_dialog_description": "Pro oznámení prostřednictvím tel. hovoru, musíte přidat a ověřit alespoň jedno telefonní číslo. Ověření lze provést pomocí SMS nebo telefonátu.",
|
||||||
|
"account_basics_phone_numbers_description": "K oznámení telefonátem",
|
||||||
|
"account_basics_phone_numbers_no_phone_numbers_yet": "Zatím žádná telefonní čísla",
|
||||||
|
"account_basics_phone_numbers_copied_to_clipboard": "Telefonní číslo zkopírováno do schránky",
|
||||||
|
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Žádná ověřená telefonní čísla",
|
||||||
|
"publish_dialog_call_item": "Vytočit číslo {{number}}"
|
||||||
}
|
}
|
||||||
|
|
|
@ -355,5 +355,29 @@
|
||||||
"account_upgrade_dialog_billing_contact_email": "Para preguntas sobre facturación, por favor <Link>contáctenos</Link> directamente.",
|
"account_upgrade_dialog_billing_contact_email": "Para preguntas sobre facturación, por favor <Link>contáctenos</Link> directamente.",
|
||||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} mensaje diario",
|
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} mensaje diario",
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} correo electrónico diario",
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} correo electrónico diario",
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} tema reservado"
|
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} tema reservado",
|
||||||
|
"publish_dialog_call_label": "Llamada telefónica",
|
||||||
|
"publish_dialog_call_placeholder": "Número de teléfono al cual llamar con el mensaje, por ejemplo +12223334444, o \"sí\"",
|
||||||
|
"publish_dialog_chip_call_label": "Llamada telefónica",
|
||||||
|
"account_basics_phone_numbers_title": "Números de teléfono",
|
||||||
|
"account_basics_phone_numbers_description": "Para notificaciones por llamada teléfonica",
|
||||||
|
"account_basics_phone_numbers_no_phone_numbers_yet": "Aún no hay números de teléfono",
|
||||||
|
"account_basics_phone_numbers_dialog_number_label": "Número de teléfono",
|
||||||
|
"account_basics_phone_numbers_dialog_number_placeholder": "p. ej. +1222333444",
|
||||||
|
"account_basics_phone_numbers_dialog_verify_button_sms": "Envía SMS",
|
||||||
|
"account_basics_phone_numbers_dialog_verify_button_call": "Llámame",
|
||||||
|
"account_basics_phone_numbers_dialog_code_label": "Código de verificación",
|
||||||
|
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||||
|
"account_basics_phone_numbers_dialog_channel_call": "Llamar",
|
||||||
|
"account_usage_calls_title": "Llamadas telefónicas realizadas",
|
||||||
|
"account_usage_calls_none": "No se pueden hacer llamadas telefónicas con esta cuenta",
|
||||||
|
"account_upgrade_dialog_tier_features_calls_one": "{{llamadas}} llamadas telefónicas diarias",
|
||||||
|
"account_upgrade_dialog_tier_features_calls_other": "{{llamadas}} llamadas telefónicas diarias",
|
||||||
|
"account_upgrade_dialog_tier_features_no_calls": "No hay llamadas telefónicas",
|
||||||
|
"publish_dialog_call_reset": "Eliminar llamada telefónica",
|
||||||
|
"account_basics_phone_numbers_dialog_description": "Para utilizar la función de notificación de llamadas, tiene que añadir y verificar al menos un número de teléfono. La verificación puede realizarse mediante un SMS o una llamada telefónica.",
|
||||||
|
"account_basics_phone_numbers_copied_to_clipboard": "Número de teléfono copiado al portapapeles",
|
||||||
|
"account_basics_phone_numbers_dialog_check_verification_button": "Confirmar código",
|
||||||
|
"account_basics_phone_numbers_dialog_title": "Agregar número de teléfono",
|
||||||
|
"account_basics_phone_numbers_dialog_code_placeholder": "p.ej. 123456"
|
||||||
}
|
}
|
||||||
|
|
|
@ -352,5 +352,24 @@
|
||||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "économisez jusqu'à {{discount}}%",
|
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "économisez jusqu'à {{discount}}%",
|
||||||
"account_upgrade_dialog_tier_price_per_month": "mois",
|
"account_upgrade_dialog_tier_price_per_month": "mois",
|
||||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} prélevé annuellement. Économisez {{save}}.",
|
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} prélevé annuellement. Économisez {{save}}.",
|
||||||
"account_upgrade_dialog_billing_contact_email": "Pour des questions concernant la facturation, merci de nous <Link>contacter</Link> directement."
|
"account_upgrade_dialog_billing_contact_email": "Pour des questions concernant la facturation, merci de nous <Link>contacter</Link> directement.",
|
||||||
|
"publish_dialog_call_label": "Appel téléphonique",
|
||||||
|
"account_basics_phone_numbers_title": "Numéros de téléphone",
|
||||||
|
"account_basics_phone_numbers_dialog_description": "Pour utiliser la fonctionnalité de notification par appels, vous devez ajouter et vérifier au moins un numéro de téléphone. La vérification peut se faire par SMS ou appel téléphonique.",
|
||||||
|
"account_basics_phone_numbers_description": "Pour des notifications par appel téléphoniques",
|
||||||
|
"account_basics_phone_numbers_no_phone_numbers_yet": "Pas encore de numéros de téléphone",
|
||||||
|
"account_basics_phone_numbers_copied_to_clipboard": "Numéro de téléphone copié dans le presse-papier",
|
||||||
|
"account_basics_phone_numbers_dialog_title": "Ajouter un numéro de téléphone",
|
||||||
|
"account_basics_phone_numbers_dialog_number_label": "Numéro de téléphone",
|
||||||
|
"account_basics_phone_numbers_dialog_number_placeholder": "Ex : +33701020304",
|
||||||
|
"account_basics_phone_numbers_dialog_verify_button_sms": "Envoyer un SMS",
|
||||||
|
"account_basics_phone_numbers_dialog_verify_button_call": "Appelez moi",
|
||||||
|
"account_basics_phone_numbers_dialog_code_label": "Code de vérification",
|
||||||
|
"account_basics_phone_numbers_dialog_code_placeholder": "Ex : 123456",
|
||||||
|
"account_basics_phone_numbers_dialog_check_verification_button": "Code de confirmarion",
|
||||||
|
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||||
|
"account_basics_phone_numbers_dialog_channel_call": "Appel",
|
||||||
|
"account_usage_calls_none": "Aucun appels téléphoniques ne peut être fait avec ce compte",
|
||||||
|
"publish_dialog_call_reset": "Supprimer les appels téléphoniques",
|
||||||
|
"publish_dialog_chip_call_label": "Appel téléphonique"
|
||||||
}
|
}
|
||||||
|
|
|
@ -379,5 +379,7 @@
|
||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} panggilan telepon harian",
|
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} panggilan telepon harian",
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon",
|
"account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon",
|
||||||
"account_basics_phone_numbers_dialog_code_label": "Kode verifikasi"
|
"account_basics_phone_numbers_dialog_code_label": "Kode verifikasi",
|
||||||
|
"publish_dialog_call_item": "Panggil nomor telepon {{number}}",
|
||||||
|
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Tidak ada nomor telepon terverifikasi"
|
||||||
}
|
}
|
||||||
|
|
|
@ -214,5 +214,17 @@
|
||||||
"login_link_signup": "Registar",
|
"login_link_signup": "Registar",
|
||||||
"action_bar_reservation_add": "Reservar tópico",
|
"action_bar_reservation_add": "Reservar tópico",
|
||||||
"action_bar_sign_up": "Registar",
|
"action_bar_sign_up": "Registar",
|
||||||
"nav_button_account": "Conta"
|
"nav_button_account": "Conta",
|
||||||
|
"common_copy_to_clipboard": "Copiar",
|
||||||
|
"nav_upgrade_banner_label": "Atualizar para ntfy Pro",
|
||||||
|
"alert_not_supported_context_description": "Notificações são suportadas apenas sobre HTTPS. Essa é uma limitação da <mdnLink>API de Notificações</mdnLink>.",
|
||||||
|
"display_name_dialog_title": "Alterar nome mostrado",
|
||||||
|
"display_name_dialog_description": "Configura um nome alternativo ao tópico que é mostrado na lista de assinaturas. Isto ajuda a identificar tópicos com nomes complicados mais facilmente.",
|
||||||
|
"display_name_dialog_placeholder": "Nome exibido",
|
||||||
|
"reserve_dialog_checkbox_label": "Reservar tópico e configurar acesso",
|
||||||
|
"publish_dialog_call_label": "Chamada telefônica",
|
||||||
|
"publish_dialog_call_placeholder": "Número de telefone para ligar com a mensagem, ex: +12223334444, ou 'Sim'",
|
||||||
|
"publish_dialog_call_reset": "Remover chamada telefônica",
|
||||||
|
"publish_dialog_chip_call_label": "Chamada telefônica",
|
||||||
|
"subscribe_dialog_subscribe_button_generate_topic_name": "Gerar nome"
|
||||||
}
|
}
|
||||||
|
|
|
@ -295,5 +295,62 @@
|
||||||
"account_usage_messages_title": "Опубліковані повідомлення",
|
"account_usage_messages_title": "Опубліковані повідомлення",
|
||||||
"account_usage_emails_title": "Надіслані електронні листи",
|
"account_usage_emails_title": "Надіслані електронні листи",
|
||||||
"account_usage_reservations_title": "Зарезервовані теми",
|
"account_usage_reservations_title": "Зарезервовані теми",
|
||||||
"account_usage_reservations_none": "Для цього облікового запису немає зарезервованих тем"
|
"account_usage_reservations_none": "Для цього облікового запису немає зарезервованих тем",
|
||||||
|
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} на файл",
|
||||||
|
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} загальне сховище",
|
||||||
|
"account_upgrade_dialog_tier_current_label": "Поточний",
|
||||||
|
"account_upgrade_dialog_tier_selected_label": "Вибране",
|
||||||
|
"account_upgrade_dialog_cancel_warning": "Це <strong> скасує вашу підписку</strong> і знизить версію вашого облікового запису {{date}}. У цю дату резервування тем, а також повідомлення, кешовані на сервері <strong>, буде видалено</strong>.",
|
||||||
|
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} зарезервовані теми",
|
||||||
|
"account_upgrade_dialog_tier_features_no_reservations": "Немає зарезервованих тем",
|
||||||
|
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} повідомлень в день",
|
||||||
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} електронний лист в день",
|
||||||
|
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} електронних листів в день",
|
||||||
|
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} телефонний дзвінок в день",
|
||||||
|
"account_upgrade_dialog_tier_features_calls_other": "{{дзвінки}} телефонних дзвінків в день",
|
||||||
|
"account_upgrade_dialog_tier_features_no_calls": "Без телефонних дзвінків",
|
||||||
|
"account_upgrade_dialog_tier_price_per_month": "місяць",
|
||||||
|
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} на рік. Рахунок виставляється щомісяця.",
|
||||||
|
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} виставляється щорічно. Збережіть {{save}}.",
|
||||||
|
"account_upgrade_dialog_billing_contact_email": "Якщо у вас виникли запитання щодо оплати, <Link>зв’яжіться з нами</Link> безпосередньо.",
|
||||||
|
"account_upgrade_dialog_billing_contact_website": "Якщо у вас виникли запитання щодо оплати, відвідайте наш <Link>веб-сайт</Link>.",
|
||||||
|
"account_upgrade_dialog_button_cancel_subscription": "Скасувати підписку",
|
||||||
|
"account_upgrade_dialog_button_update_subscription": "Оновити підписку",
|
||||||
|
"account_tokens_title": "Токени доступу",
|
||||||
|
"account_tokens_table_expires_header": "Термін дії закінчується",
|
||||||
|
"account_tokens_description": "Використовуйте токени доступу при публікації та підписці через ntfy API, щоб не надсилати свої облікові дані. Ознайомтеся з <Link>документацією</Link>, щоб дізнатися більше.",
|
||||||
|
"account_tokens_table_token_header": "Токен",
|
||||||
|
"account_tokens_table_never_expires": "Ніколи не закінчується",
|
||||||
|
"account_tokens_table_label_header": "Мітка",
|
||||||
|
"account_tokens_table_current_session": "Поточний сеанс браузера",
|
||||||
|
"account_tokens_table_last_access_header": "Останній доступ",
|
||||||
|
"account_tokens_table_copied_to_clipboard": "Токен доступу скопійовано",
|
||||||
|
"account_tokens_table_cannot_delete_or_edit": "Неможливо редагувати або видалити токен поточного сеансу",
|
||||||
|
"account_tokens_table_create_token_button": "Створити токен доступу",
|
||||||
|
"account_tokens_table_last_origin_tooltip": "З IP-адреси {{ip}} натисніть для пошуку",
|
||||||
|
"account_tokens_dialog_title_create": "Створити токен доступу",
|
||||||
|
"account_tokens_dialog_button_cancel": "Скасувати",
|
||||||
|
"account_tokens_dialog_title_edit": "Редагувати токен доступу",
|
||||||
|
"account_tokens_dialog_title_delete": "Видалити токен доступу",
|
||||||
|
"account_tokens_dialog_label": "Мітка, наприклад, сповіщення Radarr",
|
||||||
|
"account_tokens_dialog_button_create": "Створити токен",
|
||||||
|
"account_tokens_dialog_button_update": "Оновити токен",
|
||||||
|
"account_tokens_dialog_expires_label": "Термін дії токену доступу закінчується через",
|
||||||
|
"account_tokens_dialog_expires_x_hours": "Термін дії токена закінчується через {{hours}} годин",
|
||||||
|
"account_tokens_dialog_expires_x_days": "Термін дії токена закінчується через {{days}} днів",
|
||||||
|
"account_tokens_delete_dialog_description": "Перш ніж видалити токен доступу, переконайтеся, що жодна програма або скрипт не використовує його. <strong>Ця дія не може бути скасована</strong>.",
|
||||||
|
"prefs_users_description_no_sync": "Користувачі та паролі не синхронізуються з вашим акаунтом.",
|
||||||
|
"prefs_users_table_cannot_delete_or_edit": "Неможливо видалити або відредагувати користувача, який увійшов у систему",
|
||||||
|
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} зарезервована тема",
|
||||||
|
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} повідомлення в день",
|
||||||
|
"account_tokens_dialog_expires_unchanged": "Залишити термін придатності без змін",
|
||||||
|
"account_tokens_dialog_expires_never": "Термін дії токена ніколи не закінчується",
|
||||||
|
"account_tokens_delete_dialog_title": "Видалити токен доступу",
|
||||||
|
"account_tokens_delete_dialog_submit_button": "Видалити токен назавжди",
|
||||||
|
"account_upgrade_dialog_proration_info": "<strong>Пропорція</strong>: При переході з одного тарифного плану на інший різниця в ціні буде <strong>списана негайно</strong>. При переході на нижчий рівень залишок коштів буде використано для оплати майбутніх розрахункових періодів.",
|
||||||
|
"account_upgrade_dialog_reservations_warning_one": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, <strong>будь ласка, видаліть принаймні одне резервування</strong>. Ви можете видалити резервування в <Link>Налаштуваннях</Link>.",
|
||||||
|
"account_upgrade_dialog_reservations_warning_other": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, <strong>будь ласка, видаліть принаймні {{count}} резервувань</strong>. Ви можете видалити резервування в <Link>Налаштуваннях</Link>.",
|
||||||
|
"account_upgrade_dialog_button_cancel": "Скасувати",
|
||||||
|
"account_upgrade_dialog_button_redirect_signup": "Зареєструватися зараз",
|
||||||
|
"account_upgrade_dialog_button_pay_now": "Оплатити зараз і підписатися"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import i18n from "i18next";
|
||||||
import {
|
import {
|
||||||
accountBillingPortalUrl,
|
accountBillingPortalUrl,
|
||||||
accountBillingSubscriptionUrl,
|
accountBillingSubscriptionUrl,
|
||||||
|
@ -17,7 +18,6 @@ import {
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import session from "./Session";
|
import session from "./Session";
|
||||||
import subscriptionManager from "./SubscriptionManager";
|
import subscriptionManager from "./SubscriptionManager";
|
||||||
import i18n from "i18next";
|
|
||||||
import prefs from "./Prefs";
|
import prefs from "./Prefs";
|
||||||
import routes from "../components/routes";
|
import routes from "../components/routes";
|
||||||
import { fetchOrThrow, UnauthorizedError } from "./errors";
|
import { fetchOrThrow, UnauthorizedError } from "./errors";
|
||||||
|
@ -66,13 +66,13 @@ class AccountApi {
|
||||||
async create(username, password) {
|
async create(username, password) {
|
||||||
const url = accountUrl(config.base_url);
|
const url = accountUrl(config.base_url);
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
username: username,
|
username,
|
||||||
password: password,
|
password,
|
||||||
});
|
});
|
||||||
console.log(`[AccountApi] Creating user account ${url}`);
|
console.log(`[AccountApi] Creating user account ${url}`);
|
||||||
await fetchOrThrow(url, {
|
await fetchOrThrow(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: body,
|
body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -97,7 +97,7 @@ class AccountApi {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: withBearerAuth({}, session.token()),
|
headers: withBearerAuth({}, session.token()),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
password: password,
|
password,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -118,7 +118,7 @@ class AccountApi {
|
||||||
async createToken(label, expires) {
|
async createToken(label, expires) {
|
||||||
const url = accountTokenUrl(config.base_url);
|
const url = accountTokenUrl(config.base_url);
|
||||||
const body = {
|
const body = {
|
||||||
label: 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}`);
|
console.log(`[AccountApi] Creating user access token ${url}`);
|
||||||
|
@ -132,8 +132,8 @@ class AccountApi {
|
||||||
async updateToken(token, label, expires) {
|
async updateToken(token, label, expires) {
|
||||||
const url = accountTokenUrl(config.base_url);
|
const url = accountTokenUrl(config.base_url);
|
||||||
const body = {
|
const body = {
|
||||||
token: token,
|
token,
|
||||||
label: label,
|
label,
|
||||||
};
|
};
|
||||||
if (expires > 0) {
|
if (expires > 0) {
|
||||||
body.expires = Math.floor(Date.now() / 1000) + expires;
|
body.expires = Math.floor(Date.now() / 1000) + expires;
|
||||||
|
@ -171,7 +171,7 @@ class AccountApi {
|
||||||
await fetchOrThrow(url, {
|
await fetchOrThrow(url, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: withBearerAuth({}, session.token()),
|
headers: withBearerAuth({}, session.token()),
|
||||||
body: body,
|
body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -179,13 +179,13 @@ class AccountApi {
|
||||||
const url = accountSubscriptionUrl(config.base_url);
|
const url = accountSubscriptionUrl(config.base_url);
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
topic: topic,
|
topic,
|
||||||
});
|
});
|
||||||
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
|
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
|
||||||
const response = await fetchOrThrow(url, {
|
const response = await fetchOrThrow(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: withBearerAuth({}, session.token()),
|
headers: withBearerAuth({}, session.token()),
|
||||||
body: body,
|
body,
|
||||||
});
|
});
|
||||||
const subscription = await response.json(); // May throw SyntaxError
|
const subscription = await response.json(); // May throw SyntaxError
|
||||||
console.log(`[AccountApi] Subscription`, subscription);
|
console.log(`[AccountApi] Subscription`, subscription);
|
||||||
|
@ -196,14 +196,14 @@ class AccountApi {
|
||||||
const url = accountSubscriptionUrl(config.base_url);
|
const url = accountSubscriptionUrl(config.base_url);
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
topic: topic,
|
topic,
|
||||||
...payload,
|
...payload,
|
||||||
});
|
});
|
||||||
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
|
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
|
||||||
const response = await fetchOrThrow(url, {
|
const response = await fetchOrThrow(url, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: withBearerAuth({}, session.token()),
|
headers: withBearerAuth({}, session.token()),
|
||||||
body: body,
|
body,
|
||||||
});
|
});
|
||||||
const subscription = await response.json(); // May throw SyntaxError
|
const subscription = await response.json(); // May throw SyntaxError
|
||||||
console.log(`[AccountApi] Subscription`, subscription);
|
console.log(`[AccountApi] Subscription`, subscription);
|
||||||
|
@ -230,8 +230,8 @@ class AccountApi {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: withBearerAuth({}, session.token()),
|
headers: withBearerAuth({}, session.token()),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
topic: topic,
|
topic,
|
||||||
everyone: everyone,
|
everyone,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -261,25 +261,25 @@ class AccountApi {
|
||||||
|
|
||||||
async createBillingSubscription(tier, interval) {
|
async createBillingSubscription(tier, interval) {
|
||||||
console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`);
|
console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`);
|
||||||
return await this.upsertBillingSubscription("POST", tier, interval);
|
return this.upsertBillingSubscription("POST", tier, interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateBillingSubscription(tier, interval) {
|
async updateBillingSubscription(tier, interval) {
|
||||||
console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`);
|
console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`);
|
||||||
return await this.upsertBillingSubscription("PUT", tier, interval);
|
return this.upsertBillingSubscription("PUT", tier, interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertBillingSubscription(method, tier, interval) {
|
async upsertBillingSubscription(method, tier, interval) {
|
||||||
const url = accountBillingSubscriptionUrl(config.base_url);
|
const url = accountBillingSubscriptionUrl(config.base_url);
|
||||||
const response = await fetchOrThrow(url, {
|
const response = await fetchOrThrow(url, {
|
||||||
method: method,
|
method,
|
||||||
headers: withBearerAuth({}, session.token()),
|
headers: withBearerAuth({}, session.token()),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tier: tier,
|
tier,
|
||||||
interval: interval,
|
interval,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return await response.json(); // May throw SyntaxError
|
return response.json(); // May throw SyntaxError
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteBillingSubscription() {
|
async deleteBillingSubscription() {
|
||||||
|
@ -298,7 +298,7 @@ class AccountApi {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: withBearerAuth({}, session.token()),
|
headers: withBearerAuth({}, session.token()),
|
||||||
});
|
});
|
||||||
return await response.json(); // May throw SyntaxError
|
return response.json(); // May throw SyntaxError
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyPhoneNumber(phoneNumber, channel) {
|
async verifyPhoneNumber(phoneNumber, channel) {
|
||||||
|
@ -309,7 +309,7 @@ class AccountApi {
|
||||||
headers: withBearerAuth({}, session.token()),
|
headers: withBearerAuth({}, session.token()),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
number: phoneNumber,
|
number: phoneNumber,
|
||||||
channel: channel,
|
channel,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -322,12 +322,12 @@ class AccountApi {
|
||||||
headers: withBearerAuth({}, session.token()),
|
headers: withBearerAuth({}, session.token()),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
number: phoneNumber,
|
number: phoneNumber,
|
||||||
code: code,
|
code,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deletePhoneNumber(phoneNumber, code) {
|
async deletePhoneNumber(phoneNumber) {
|
||||||
const url = accountPhoneUrl(config.base_url);
|
const url = accountPhoneUrl(config.base_url);
|
||||||
console.log(`[AccountApi] Deleting phone number ${url}`);
|
console.log(`[AccountApi] Deleting phone number ${url}`);
|
||||||
await fetchOrThrow(url, {
|
await fetchOrThrow(url, {
|
||||||
|
@ -369,6 +369,7 @@ class AccountApi {
|
||||||
if (e instanceof UnauthorizedError) {
|
if (e instanceof UnauthorizedError) {
|
||||||
session.resetAndRedirect(routes.login);
|
session.resetAndRedirect(routes.login);
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ class Api {
|
||||||
const messages = [];
|
const messages = [];
|
||||||
const headers = maybeWithAuth({}, user);
|
const headers = maybeWithAuth({}, user);
|
||||||
console.log(`[Api] Polling ${url}`);
|
console.log(`[Api] Polling ${url}`);
|
||||||
for await (let line of fetchLinesIterator(url, headers)) {
|
for await (const line of fetchLinesIterator(url, headers)) {
|
||||||
const message = JSON.parse(line);
|
const message = JSON.parse(line);
|
||||||
if (message.id) {
|
if (message.id) {
|
||||||
console.log(`[Api, ${shortUrl}] Received message ${line}`);
|
console.log(`[Api, ${shortUrl}] Received message ${line}`);
|
||||||
|
@ -33,8 +33,8 @@ class Api {
|
||||||
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
|
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
|
||||||
const headers = {};
|
const headers = {};
|
||||||
const body = {
|
const body = {
|
||||||
topic: topic,
|
topic,
|
||||||
message: message,
|
message,
|
||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
await fetchOrThrow(baseUrl, {
|
await fetchOrThrow(baseUrl, {
|
||||||
|
@ -60,7 +60,7 @@ class Api {
|
||||||
publishXHR(url, body, headers, onProgress) {
|
publishXHR(url, body, headers, onProgress) {
|
||||||
console.log(`[Api] Publishing message to ${url}`);
|
console.log(`[Api] Publishing message to ${url}`);
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
const send = new Promise(function (resolve, reject) {
|
const send = new Promise((resolve, reject) => {
|
||||||
xhr.open("PUT", url);
|
xhr.open("PUT", url);
|
||||||
if (body.type) {
|
if (body.type) {
|
||||||
xhr.overrideMimeType(body.type);
|
xhr.overrideMimeType(body.type);
|
||||||
|
@ -106,7 +106,8 @@ class Api {
|
||||||
});
|
});
|
||||||
if (response.status >= 200 && response.status <= 299) {
|
if (response.status >= 200 && response.status <= 299) {
|
||||||
return true;
|
return true;
|
||||||
} else if (response.status === 401 || response.status === 403) {
|
}
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
// See server/server.go
|
// See server/server.go
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,14 @@
|
||||||
|
/* eslint-disable max-classes-per-file */
|
||||||
import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils";
|
import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils";
|
||||||
|
|
||||||
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
|
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
|
||||||
|
|
||||||
|
export class ConnectionState {
|
||||||
|
static Connected = "connected";
|
||||||
|
|
||||||
|
static Connecting = "connecting";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A connection contains a single WebSocket connection for one topic. It handles its connection
|
* A connection contains a single WebSocket connection for one topic. It handles its connection
|
||||||
* status itself, including reconnect attempts and backoff.
|
* status itself, including reconnect attempts and backoff.
|
||||||
|
@ -63,7 +70,7 @@ class Connection {
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
} else {
|
} else {
|
||||||
const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length - 1)];
|
const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length - 1)];
|
||||||
this.retryCount++;
|
this.retryCount += 1;
|
||||||
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.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
|
||||||
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
|
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
|
||||||
|
@ -77,7 +84,7 @@ class Connection {
|
||||||
close() {
|
close() {
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`);
|
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`);
|
||||||
const socket = this.ws;
|
const socket = this.ws;
|
||||||
const retryTimeout = this.retryTimeout;
|
const { retryTimeout } = this;
|
||||||
if (socket !== null) {
|
if (socket !== null) {
|
||||||
socket.close();
|
socket.close();
|
||||||
}
|
}
|
||||||
|
@ -108,9 +115,4 @@ class Connection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConnectionState {
|
|
||||||
static Connected = "connected";
|
|
||||||
static Connecting = "connecting";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Connection;
|
export default Connection;
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import Connection from "./Connection";
|
import Connection from "./Connection";
|
||||||
import { hashCode } from "./utils";
|
import { hashCode } from "./utils";
|
||||||
|
|
||||||
|
const makeConnectionId = async (subscription, user) =>
|
||||||
|
user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The connection manager keeps track of active connections (WebSocket connections, see Connection).
|
* The connection manager keeps track of active connections (WebSocket connections, see Connection).
|
||||||
*
|
*
|
||||||
|
@ -55,12 +58,10 @@ class ConnectionManager {
|
||||||
// Create and add new connections
|
// Create and add new connections
|
||||||
subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
|
subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
|
||||||
const subscriptionId = subscription.id;
|
const subscriptionId = subscription.id;
|
||||||
const connectionId = subscription.connectionId;
|
const { connectionId } = subscription;
|
||||||
const added = !this.connections.get(connectionId);
|
const added = !this.connections.get(connectionId);
|
||||||
if (added) {
|
if (added) {
|
||||||
const baseUrl = subscription.baseUrl;
|
const { baseUrl, topic, user } = subscription;
|
||||||
const topic = subscription.topic;
|
|
||||||
const user = subscription.user;
|
|
||||||
const since = subscription.last;
|
const since = subscription.last;
|
||||||
const connection = new Connection(
|
const connection = new Connection(
|
||||||
connectionId,
|
connectionId,
|
||||||
|
@ -69,8 +70,8 @@ class ConnectionManager {
|
||||||
topic,
|
topic,
|
||||||
user,
|
user,
|
||||||
since,
|
since,
|
||||||
(subscriptionId, notification) => this.notificationReceived(subscriptionId, notification),
|
(subId, notification) => this.notificationReceived(subId, notification),
|
||||||
(subscriptionId, state) => this.stateChanged(subscriptionId, state)
|
(subId, state) => this.stateChanged(subId, state)
|
||||||
);
|
);
|
||||||
this.connections.set(connectionId, connection);
|
this.connections.set(connectionId, connection);
|
||||||
console.log(
|
console.log(
|
||||||
|
@ -112,9 +113,5 @@ class ConnectionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const makeConnectionId = async (subscription, user) => {
|
|
||||||
return user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectionManager = new ConnectionManager();
|
const connectionManager = new ConnectionManager();
|
||||||
export default connectionManager;
|
export default connectionManager;
|
||||||
|
|
|
@ -29,7 +29,7 @@ class Notifier {
|
||||||
icon: logo,
|
icon: logo,
|
||||||
});
|
});
|
||||||
if (notification.click) {
|
if (notification.click) {
|
||||||
n.onclick = (e) => openUrl(notification.click);
|
n.onclick = () => openUrl(notification.click);
|
||||||
} else {
|
} else {
|
||||||
n.onclick = () => onClickFallback(subscription);
|
n.onclick = () => onClickFallback(subscription);
|
||||||
}
|
}
|
||||||
|
@ -87,7 +87,7 @@ class Notifier {
|
||||||
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
|
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
|
||||||
*/
|
*/
|
||||||
contextSupported() {
|
contextSupported() {
|
||||||
return location.protocol === "https:" || location.hostname.match("^127.") || location.hostname === "localhost";
|
return window.location.protocol === "https:" || window.location.hostname.match("^127.") || window.location.hostname === "localhost";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,13 +21,16 @@ class Poller {
|
||||||
async pollAll() {
|
async pollAll() {
|
||||||
console.log(`[Poller] Polling all subscriptions`);
|
console.log(`[Poller] Polling all subscriptions`);
|
||||||
const subscriptions = await subscriptionManager.all();
|
const subscriptions = await subscriptionManager.all();
|
||||||
for (const s of subscriptions) {
|
|
||||||
|
await Promise.all(
|
||||||
|
subscriptions.map(async (s) => {
|
||||||
try {
|
try {
|
||||||
await this.poll(s);
|
await this.poll(s);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[Poller] Error polling ${s.id}`, e);
|
console.log(`[Poller] Error polling ${s.id}`, e);
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async poll(subscription) {
|
async poll(subscription) {
|
||||||
|
|
|
@ -5,16 +5,16 @@ class SubscriptionManager {
|
||||||
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
|
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
|
||||||
async all() {
|
async all() {
|
||||||
const subscriptions = await db.subscriptions.toArray();
|
const subscriptions = await db.subscriptions.toArray();
|
||||||
await Promise.all(
|
return Promise.all(
|
||||||
subscriptions.map(async (s) => {
|
subscriptions.map(async (s) => ({
|
||||||
s.new = await db.notifications.where({ subscriptionId: s.id, new: 1 }).count();
|
...s,
|
||||||
})
|
new: await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(),
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
return subscriptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(subscriptionId) {
|
async get(subscriptionId) {
|
||||||
return await db.subscriptions.get(subscriptionId);
|
return db.subscriptions.get(subscriptionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async add(baseUrl, topic, internal) {
|
async add(baseUrl, topic, internal) {
|
||||||
|
@ -25,8 +25,8 @@ class SubscriptionManager {
|
||||||
}
|
}
|
||||||
const subscription = {
|
const subscription = {
|
||||||
id: topicUrl(baseUrl, topic),
|
id: topicUrl(baseUrl, topic),
|
||||||
baseUrl: baseUrl,
|
baseUrl,
|
||||||
topic: topic,
|
topic,
|
||||||
mutedUntil: 0,
|
mutedUntil: 0,
|
||||||
last: null,
|
last: null,
|
||||||
internal: internal || false,
|
internal: internal || false,
|
||||||
|
@ -39,36 +39,40 @@ class SubscriptionManager {
|
||||||
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
|
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
|
||||||
|
|
||||||
// Add remote subscriptions
|
// Add remote subscriptions
|
||||||
let remoteIds = []; // = topicUrl(baseUrl, topic)
|
const remoteIds = await Promise.all(
|
||||||
for (let i = 0; i < remoteSubscriptions.length; i++) {
|
remoteSubscriptions.map(async (remote) => {
|
||||||
const remote = remoteSubscriptions[i];
|
|
||||||
const local = await this.add(remote.base_url, remote.topic, false);
|
const local = await this.add(remote.base_url, remote.topic, false);
|
||||||
const reservation = 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, {
|
await this.update(local.id, {
|
||||||
displayName: remote.display_name, // May be undefined
|
displayName: remote.display_name, // May be undefined
|
||||||
reservation: reservation, // May be null!
|
reservation, // May be null!
|
||||||
});
|
});
|
||||||
remoteIds.push(local.id);
|
|
||||||
}
|
return local.id;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Remove local subscriptions that do not exist remotely
|
// Remove local subscriptions that do not exist remotely
|
||||||
const localSubscriptions = await db.subscriptions.toArray();
|
const localSubscriptions = await db.subscriptions.toArray();
|
||||||
for (let i = 0; i < localSubscriptions.length; i++) {
|
|
||||||
const local = localSubscriptions[i];
|
await Promise.all(
|
||||||
|
localSubscriptions.map(async (local) => {
|
||||||
const remoteExists = remoteIds.includes(local.id);
|
const remoteExists = remoteIds.includes(local.id);
|
||||||
if (!local.internal && !remoteExists) {
|
if (!local.internal && !remoteExists) {
|
||||||
await this.remove(local.id);
|
await this.remove(local.id);
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateState(subscriptionId, state) {
|
async updateState(subscriptionId, state) {
|
||||||
db.subscriptions.update(subscriptionId, { state: state });
|
db.subscriptions.update(subscriptionId, { state });
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(subscriptionId) {
|
async remove(subscriptionId) {
|
||||||
await db.subscriptions.delete(subscriptionId);
|
await db.subscriptions.delete(subscriptionId);
|
||||||
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
|
await db.notifications.where({ subscriptionId }).delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
async first() {
|
async first() {
|
||||||
|
@ -101,8 +105,12 @@ class SubscriptionManager {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
await db.notifications.add({
|
||||||
await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab
|
...notification,
|
||||||
|
subscriptionId,
|
||||||
|
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||||
|
new: 1,
|
||||||
|
}); // FIXME consider put() for double tab
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await db.subscriptions.update(subscriptionId, {
|
||||||
last: notification.id,
|
last: notification.id,
|
||||||
});
|
});
|
||||||
|
@ -140,7 +148,7 @@ class SubscriptionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteNotifications(subscriptionId) {
|
async deleteNotifications(subscriptionId) {
|
||||||
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
|
await db.notifications.where({ subscriptionId }).delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
async markNotificationRead(notificationId) {
|
async markNotificationRead(notificationId) {
|
||||||
|
@ -148,24 +156,24 @@ class SubscriptionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
async markNotificationsRead(subscriptionId) {
|
async markNotificationsRead(subscriptionId) {
|
||||||
await db.notifications.where({ subscriptionId: subscriptionId, new: 1 }).modify({ new: 0 });
|
await db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMutedUntil(subscriptionId, mutedUntil) {
|
async setMutedUntil(subscriptionId, mutedUntil) {
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await db.subscriptions.update(subscriptionId, {
|
||||||
mutedUntil: mutedUntil,
|
mutedUntil,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDisplayName(subscriptionId, displayName) {
|
async setDisplayName(subscriptionId, displayName) {
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await db.subscriptions.update(subscriptionId, {
|
||||||
displayName: displayName,
|
displayName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setReservation(subscriptionId, reservation) {
|
async setReservation(subscriptionId, reservation) {
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await db.subscriptions.update(subscriptionId, {
|
||||||
reservation: reservation,
|
reservation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
const config = window.config;
|
const { config } = window;
|
||||||
|
|
||||||
// The backend returns an empty base_url for the config struct,
|
// The backend returns an empty base_url for the config struct,
|
||||||
// so the frontend (hey, that's us!) can use the current location.
|
// so the frontend (hey, that's us!) can use the current location.
|
||||||
|
|
|
@ -1,13 +1,52 @@
|
||||||
|
/* eslint-disable max-classes-per-file */
|
||||||
// This is a subset of, and the counterpart to errors.go
|
// This is a subset of, and the counterpart to errors.go
|
||||||
|
|
||||||
export const fetchOrThrow = async (url, options) => {
|
const maybeToJson = async (response) => {
|
||||||
const response = await fetch(url, options);
|
try {
|
||||||
if (response.status !== 200) {
|
return await response.json();
|
||||||
await throwAppError(response);
|
} catch (e) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return response; // Promise!
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class UnauthorizedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Unauthorized");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserExistsError extends Error {
|
||||||
|
static CODE = 40901; // errHTTPConflictUserExists
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("Username already exists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TopicReservedError extends Error {
|
||||||
|
static CODE = 40902; // errHTTPConflictTopicReserved
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("Topic already reserved");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AccountCreateLimitReachedError extends Error {
|
||||||
|
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("Account creation limit reached");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncorrectPasswordError extends Error {
|
||||||
|
static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("Password incorrect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const throwAppError = async (response) => {
|
export const throwAppError = async (response) => {
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
console.log(`[Error] HTTP ${response.status}`, response);
|
console.log(`[Error] HTTP ${response.status}`, response);
|
||||||
|
@ -32,44 +71,10 @@ export const throwAppError = async (response) => {
|
||||||
throw new Error(`Unexpected response ${response.status}`);
|
throw new Error(`Unexpected response ${response.status}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeToJson = async (response) => {
|
export const fetchOrThrow = async (url, options) => {
|
||||||
try {
|
const response = await fetch(url, options);
|
||||||
return await response.json();
|
if (response.status !== 200) {
|
||||||
} catch (e) {
|
await throwAppError(response);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return response; // Promise!
|
||||||
};
|
};
|
||||||
|
|
||||||
export class UnauthorizedError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Unauthorized");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserExistsError extends Error {
|
|
||||||
static CODE = 40901; // errHTTPConflictUserExists
|
|
||||||
constructor() {
|
|
||||||
super("Username already exists");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TopicReservedError extends Error {
|
|
||||||
static CODE = 40902; // errHTTPConflictTopicReserved
|
|
||||||
constructor() {
|
|
||||||
super("Topic already reserved");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AccountCreateLimitReachedError extends Error {
|
|
||||||
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
|
|
||||||
constructor() {
|
|
||||||
super("Account creation limit reached");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class IncorrectPasswordError extends Error {
|
|
||||||
static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation
|
|
||||||
constructor() {
|
|
||||||
super("Password incorrect");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Base64 } from "js-base64";
|
||||||
import { rawEmojis } from "./emojis";
|
import { rawEmojis } from "./emojis";
|
||||||
import beep from "../sounds/beep.mp3";
|
import beep from "../sounds/beep.mp3";
|
||||||
import juntos from "../sounds/juntos.mp3";
|
import juntos from "../sounds/juntos.mp3";
|
||||||
|
@ -7,8 +8,11 @@ import dadum from "../sounds/dadum.mp3";
|
||||||
import pop from "../sounds/pop.mp3";
|
import pop from "../sounds/pop.mp3";
|
||||||
import popSwoosh from "../sounds/pop-swoosh.mp3";
|
import popSwoosh from "../sounds/pop-swoosh.mp3";
|
||||||
import config from "./config";
|
import config from "./config";
|
||||||
import { Base64 } from "js-base64";
|
|
||||||
|
|
||||||
|
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 topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
|
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
|
||||||
export const topicUrlWs = (baseUrl, topic) =>
|
export const topicUrlWs = (baseUrl, topic) =>
|
||||||
`${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://");
|
`${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://");
|
||||||
|
@ -28,14 +32,10 @@ export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account
|
||||||
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
|
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
|
||||||
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
|
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
|
||||||
export const accountPhoneVerifyUrl = (baseUrl) => `${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) => {
|
export const validUrl = (url) => url.match(/^https?:\/\/.+/);
|
||||||
return url.match(/^https?:\/\/.+/);
|
|
||||||
};
|
export const disallowedTopic = (topic) => config.disallowed_topics.includes(topic);
|
||||||
|
|
||||||
export const validTopic = (topic) => {
|
export const validTopic = (topic) => {
|
||||||
if (disallowedTopic(topic)) {
|
if (disallowedTopic(topic)) {
|
||||||
|
@ -44,14 +44,11 @@ export const validTopic = (topic) => {
|
||||||
return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
|
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) => {
|
export const topicDisplayName = (subscription) => {
|
||||||
if (subscription.displayName) {
|
if (subscription.displayName) {
|
||||||
return subscription.displayName;
|
return subscription.displayName;
|
||||||
} else if (subscription.baseUrl === config.base_url) {
|
}
|
||||||
|
if (subscription.baseUrl === config.base_url) {
|
||||||
return subscription.topic;
|
return subscription.topic;
|
||||||
}
|
}
|
||||||
return topicShortUrl(subscription.baseUrl, subscription.topic);
|
return topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||||
|
@ -67,7 +64,15 @@ rawEmojis.forEach((emoji) => {
|
||||||
|
|
||||||
const toEmojis = (tags) => {
|
const toEmojis = (tags) => {
|
||||||
if (!tags) return [];
|
if (!tags) return [];
|
||||||
else return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]);
|
return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatTitle = (m) => {
|
||||||
|
const emojiList = toEmojis(m.tags);
|
||||||
|
if (emojiList.length > 0) {
|
||||||
|
return `${emojiList.join(" ")} ${m.title}`;
|
||||||
|
}
|
||||||
|
return m.title;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatTitleWithDefault = (m, fallback) => {
|
export const formatTitleWithDefault = (m, fallback) => {
|
||||||
|
@ -77,41 +82,31 @@ export const formatTitleWithDefault = (m, fallback) => {
|
||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatTitle = (m) => {
|
|
||||||
const emojiList = toEmojis(m.tags);
|
|
||||||
if (emojiList.length > 0) {
|
|
||||||
return `${emojiList.join(" ")} ${m.title}`;
|
|
||||||
} else {
|
|
||||||
return m.title;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatMessage = (m) => {
|
export const formatMessage = (m) => {
|
||||||
if (m.title) {
|
if (m.title) {
|
||||||
return m.message;
|
return m.message;
|
||||||
} else {
|
}
|
||||||
const emojiList = toEmojis(m.tags);
|
const emojiList = toEmojis(m.tags);
|
||||||
if (emojiList.length > 0) {
|
if (emojiList.length > 0) {
|
||||||
return `${emojiList.join(" ")} ${m.message}`;
|
return `${emojiList.join(" ")} ${m.message}`;
|
||||||
} else {
|
}
|
||||||
return m.message;
|
return m.message;
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unmatchedTags = (tags) => {
|
export const unmatchedTags = (tags) => {
|
||||||
if (!tags) return [];
|
if (!tags) return [];
|
||||||
else return tags.filter((tag) => !(tag in emojis));
|
return tags.filter((tag) => !(tag in emojis));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const maybeWithAuth = (headers, user) => {
|
export const encodeBase64 = (s) => Base64.encode(s);
|
||||||
if (user && user.password) {
|
|
||||||
return withBasicAuth(headers, user.username, user.password);
|
export const encodeBase64Url = (s) => Base64.encodeURI(s);
|
||||||
} else if (user && user.token) {
|
|
||||||
return withBearerAuth(headers, user.token);
|
export const bearerAuth = (token) => `Bearer ${token}`;
|
||||||
}
|
|
||||||
return headers;
|
export const basicAuth = (username, password) => `Basic ${encodeBase64(`${username}:${password}`)}`;
|
||||||
};
|
|
||||||
|
export const withBearerAuth = (headers, token) => ({ ...headers, Authorization: bearerAuth(token) });
|
||||||
|
|
||||||
export const maybeWithBearerAuth = (headers, token) => {
|
export const maybeWithBearerAuth = (headers, token) => {
|
||||||
if (token) {
|
if (token) {
|
||||||
|
@ -120,32 +115,18 @@ export const maybeWithBearerAuth = (headers, token) => {
|
||||||
return headers;
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const withBasicAuth = (headers, username, password) => {
|
export const withBasicAuth = (headers, username, password) => ({ ...headers, Authorization: basicAuth(username, password) });
|
||||||
headers["Authorization"] = basicAuth(username, password);
|
|
||||||
|
export const maybeWithAuth = (headers, user) => {
|
||||||
|
if (user?.password) {
|
||||||
|
return withBasicAuth(headers, user.username, user.password);
|
||||||
|
}
|
||||||
|
if (user?.token) {
|
||||||
|
return withBearerAuth(headers, user.token);
|
||||||
|
}
|
||||||
return headers;
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const basicAuth = (username, password) => {
|
|
||||||
return `Basic ${encodeBase64(`${username}:${password}`)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const withBearerAuth = (headers, 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) => {
|
export const maybeAppendActionErrors = (message, notification) => {
|
||||||
const actionErrors = (notification.actions ?? [])
|
const actionErrors = (notification.actions ?? [])
|
||||||
.map((action) => action.error)
|
.map((action) => action.error)
|
||||||
|
@ -153,50 +134,47 @@ export const maybeAppendActionErrors = (message, notification) => {
|
||||||
.join("\n");
|
.join("\n");
|
||||||
if (actionErrors.length === 0) {
|
if (actionErrors.length === 0) {
|
||||||
return message;
|
return message;
|
||||||
} else {
|
|
||||||
return `${message}\n\n${actionErrors}`;
|
|
||||||
}
|
}
|
||||||
|
return `${message}\n\n${actionErrors}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shuffle = (arr) => {
|
export const shuffle = (arr) => {
|
||||||
let j, x;
|
const returnArr = [...arr];
|
||||||
for (let index = arr.length - 1; index > 0; index--) {
|
|
||||||
j = Math.floor(Math.random() * (index + 1));
|
for (let index = returnArr.length - 1; index > 0; index -= 1) {
|
||||||
x = arr[index];
|
const j = Math.floor(Math.random() * (index + 1));
|
||||||
arr[index] = arr[j];
|
[returnArr[index], returnArr[j]] = [returnArr[j], returnArr[index]];
|
||||||
arr[j] = x;
|
|
||||||
}
|
}
|
||||||
return arr;
|
|
||||||
|
return returnArr;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const splitNoEmpty = (s, delimiter) => {
|
export const splitNoEmpty = (s, delimiter) =>
|
||||||
return s
|
s
|
||||||
.split(delimiter)
|
.split(delimiter)
|
||||||
.map((x) => x.trim())
|
.map((x) => x.trim())
|
||||||
.filter((x) => x !== "");
|
.filter((x) => x !== "");
|
||||||
};
|
|
||||||
|
|
||||||
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
|
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
|
||||||
export const hashCode = async (s) => {
|
export const hashCode = async (s) => {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < s.length; i++) {
|
for (let i = 0; i < s.length; i += 1) {
|
||||||
const char = s.charCodeAt(i);
|
const char = s.charCodeAt(i);
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
hash = (hash << 5) - hash + char;
|
hash = (hash << 5) - hash + char;
|
||||||
hash = hash & hash; // Convert to 32bit integer
|
// eslint-disable-next-line no-bitwise
|
||||||
|
hash &= hash; // Convert to 32bit integer
|
||||||
}
|
}
|
||||||
return hash;
|
return hash;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatShortDateTime = (timestamp) => {
|
export const formatShortDateTime = (timestamp) =>
|
||||||
return new Intl.DateTimeFormat("default", {
|
new Intl.DateTimeFormat("default", {
|
||||||
dateStyle: "short",
|
dateStyle: "short",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
}).format(new Date(timestamp * 1000));
|
}).format(new Date(timestamp * 1000));
|
||||||
};
|
|
||||||
|
|
||||||
export const formatShortDate = (timestamp) => {
|
export const formatShortDate = (timestamp) => 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) => {
|
export const formatBytes = (bytes, decimals = 2) => {
|
||||||
if (bytes === 0) return "0 bytes";
|
if (bytes === 0) return "0 bytes";
|
||||||
|
@ -204,13 +182,14 @@ export const formatBytes = (bytes, decimals = 2) => {
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
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));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatNumber = (n) => {
|
export const formatNumber = (n) => {
|
||||||
if (n === 0) {
|
if (n === 0) {
|
||||||
return n;
|
return n;
|
||||||
} else if (n % 1000 === 0) {
|
}
|
||||||
|
if (n % 1000 === 0) {
|
||||||
return `${n / 1000}k`;
|
return `${n / 1000}k`;
|
||||||
}
|
}
|
||||||
return n.toLocaleString();
|
return n.toLocaleString();
|
||||||
|
@ -264,10 +243,11 @@ export const playSound = async (id) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
||||||
|
// eslint-disable-next-line func-style
|
||||||
export async function* fetchLinesIterator(fileURL, headers) {
|
export async function* fetchLinesIterator(fileURL, headers) {
|
||||||
const utf8Decoder = new TextDecoder("utf-8");
|
const utf8Decoder = new TextDecoder("utf-8");
|
||||||
const response = await fetch(fileURL, {
|
const response = await fetch(fileURL, {
|
||||||
headers: headers,
|
headers,
|
||||||
});
|
});
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
let { value: chunk, done: readerDone } = await reader.read();
|
let { value: chunk, done: readerDone } = await reader.read();
|
||||||
|
@ -277,15 +257,18 @@ export async function* fetchLinesIterator(fileURL, headers) {
|
||||||
let startIndex = 0;
|
let startIndex = 0;
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
let result = re.exec(chunk);
|
const result = re.exec(chunk);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
if (readerDone) {
|
if (readerDone) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let remainder = chunk.substr(startIndex);
|
const remainder = chunk.substr(startIndex);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
({ value: chunk, done: readerDone } = await reader.read());
|
({ value: chunk, done: readerDone } = await reader.read());
|
||||||
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : "");
|
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : "");
|
||||||
startIndex = re.lastIndex = 0;
|
startIndex = 0;
|
||||||
|
re.lastIndex = 0;
|
||||||
|
// eslint-disable-next-line no-continue
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
yield chunk.substring(startIndex, result.index);
|
yield chunk.substring(startIndex, result.index);
|
||||||
|
@ -299,7 +282,8 @@ export async function* fetchLinesIterator(fileURL, headers) {
|
||||||
export const randomAlphanumericString = (len) => {
|
export const randomAlphanumericString = (len) => {
|
||||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
let id = "";
|
let id = "";
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i += 1) {
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
id += alphabet[(Math.random() * alphabet.length) | 0];
|
id += alphabet[(Math.random() * alphabet.length) | 0];
|
||||||
}
|
}
|
||||||
return id;
|
return id;
|
||||||
|
|
|
@ -21,42 +21,42 @@ import {
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
Container,
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
TextField,
|
||||||
|
IconButton,
|
||||||
|
MenuItem,
|
||||||
|
DialogContentText,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
import Container from "@mui/material/Container";
|
|
||||||
import Card from "@mui/material/Card";
|
|
||||||
import Button from "@mui/material/Button";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import session from "../app/Session";
|
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
import theme from "./theme";
|
|
||||||
import Dialog from "@mui/material/Dialog";
|
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
|
||||||
import TextField from "@mui/material/TextField";
|
|
||||||
import routes from "./routes";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
|
|
||||||
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
|
|
||||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||||
import { Pref, PrefGroup } from "./Pref";
|
|
||||||
import db from "../app/db";
|
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import humanizeDuration from "humanize-duration";
|
import humanizeDuration from "humanize-duration";
|
||||||
import UpgradeDialog from "./UpgradeDialog";
|
|
||||||
import CelebrationIcon from "@mui/icons-material/Celebration";
|
import CelebrationIcon from "@mui/icons-material/Celebration";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import { ContentCopy, Public } from "@mui/icons-material";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import routes from "./routes";
|
||||||
|
import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
|
||||||
|
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
|
||||||
|
import { Pref, PrefGroup } from "./Pref";
|
||||||
|
import db from "../app/db";
|
||||||
|
import UpgradeDialog from "./UpgradeDialog";
|
||||||
import { AccountContext } from "./App";
|
import { AccountContext } from "./App";
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
import { Paragraph } from "./styles";
|
import { Paragraph } from "./styles";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
|
||||||
import { ContentCopy, Public } from "@mui/icons-material";
|
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import DialogContentText from "@mui/material/DialogContentText";
|
|
||||||
import { IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
import { IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
||||||
import { ProChip } from "./SubscriptionPopup";
|
import { ProChip } from "./SubscriptionPopup";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import theme from "./theme";
|
||||||
|
import session from "../app/Session";
|
||||||
|
|
||||||
const Account = () => {
|
const Account = () => {
|
||||||
if (!session.exists()) {
|
if (!session.exists()) {
|
||||||
|
@ -439,23 +439,6 @@ const AddPhoneNumberDialog = (props) => {
|
||||||
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
|
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
|
||||||
const handleDialogSubmit = async () => {
|
|
||||||
if (!verificationCodeSent) {
|
|
||||||
await verifyPhone();
|
|
||||||
} else {
|
|
||||||
await checkVerifyPhone();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
if (verificationCodeSent) {
|
|
||||||
setVerificationCodeSent(false);
|
|
||||||
setCode("");
|
|
||||||
} else {
|
|
||||||
props.onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const verifyPhone = async () => {
|
const verifyPhone = async () => {
|
||||||
try {
|
try {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
@ -490,6 +473,23 @@ const AddPhoneNumberDialog = (props) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDialogSubmit = async () => {
|
||||||
|
if (!verificationCodeSent) {
|
||||||
|
await verifyPhone();
|
||||||
|
} else {
|
||||||
|
await checkVerifyPhone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (verificationCodeSent) {
|
||||||
|
setVerificationCodeSent(false);
|
||||||
|
setCode("");
|
||||||
|
} else {
|
||||||
|
props.onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||||
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
|
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
|
||||||
|
@ -561,9 +561,7 @@ const Stats = () => {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalize = (value, max) => {
|
const normalize = (value, max) => Math.min((value / max) * 100, 100);
|
||||||
return Math.min((value / max) * 100, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{ p: 3 }} aria-label={t("account_usage_title")}>
|
<Card sx={{ p: 3 }} aria-label={t("account_usage_title")}>
|
||||||
|
@ -746,8 +744,7 @@ const Stats = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const InfoIcon = () => {
|
const InfoIcon = () => (
|
||||||
return (
|
|
||||||
<InfoOutlinedIcon
|
<InfoOutlinedIcon
|
||||||
sx={{
|
sx={{
|
||||||
verticalAlign: "middle",
|
verticalAlign: "middle",
|
||||||
|
@ -757,7 +754,6 @@ const InfoIcon = () => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const Tokens = () => {
|
const Tokens = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -775,10 +771,6 @@ const Tokens = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDialogSubmit = async (user) => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
//
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
|
<Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
|
||||||
<CardContent sx={{ paddingBottom: 1 }}>
|
<CardContent sx={{ paddingBottom: 1 }}>
|
||||||
|
@ -814,7 +806,8 @@ const TokensTable = (props) => {
|
||||||
const tokens = (props.tokens || []).sort((a, b) => {
|
const tokens = (props.tokens || []).sort((a, b) => {
|
||||||
if (a.token === session.token()) {
|
if (a.token === session.token()) {
|
||||||
return -1;
|
return -1;
|
||||||
} else if (b.token === session.token()) {
|
}
|
||||||
|
if (b.token === session.token()) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
return a.token.localeCompare(b.token);
|
return a.token.localeCompare(b.token);
|
||||||
|
@ -1025,7 +1018,7 @@ const TokenDeleteDialog = (props) => {
|
||||||
<Trans i18nKey="account_tokens_delete_dialog_description" />
|
<Trans i18nKey="account_tokens_delete_dialog_description" />
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter status>
|
<DialogFooter status={error}>
|
||||||
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||||
<Button onClick={handleSubmit} color="error">
|
<Button onClick={handleSubmit} color="error">
|
||||||
{t("account_tokens_delete_dialog_submit_button")}
|
{t("account_tokens_delete_dialog_submit_button")}
|
|
@ -1,29 +1,21 @@
|
||||||
import AppBar from "@mui/material/AppBar";
|
import { AppBar, Toolbar, IconButton, Typography, Box, MenuItem, Button, Divider, ListItemIcon } from "@mui/material";
|
||||||
import Navigation from "./Navigation";
|
|
||||||
import Toolbar from "@mui/material/Toolbar";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import MenuIcon from "@mui/icons-material/Menu";
|
import MenuIcon from "@mui/icons-material/Menu";
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import { topicDisplayName } from "../app/utils";
|
|
||||||
import db from "../app/db";
|
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
import NotificationsIcon from "@mui/icons-material/Notifications";
|
import NotificationsIcon from "@mui/icons-material/Notifications";
|
||||||
import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
|
import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
|
||||||
import routes from "./routes";
|
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
|
||||||
import logo from "../img/ntfy.svg";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import session from "../app/Session";
|
|
||||||
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
|
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
|
||||||
import Button from "@mui/material/Button";
|
|
||||||
import Divider from "@mui/material/Divider";
|
|
||||||
import { Logout, Person, Settings } from "@mui/icons-material";
|
import { Logout, Person, Settings } from "@mui/icons-material";
|
||||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
import session from "../app/Session";
|
||||||
|
import logo from "../img/ntfy.svg";
|
||||||
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
|
import routes from "./routes";
|
||||||
|
import db from "../app/db";
|
||||||
|
import { topicDisplayName } from "../app/utils";
|
||||||
|
import Navigation from "./Navigation";
|
||||||
import accountApi from "../app/AccountApi";
|
import accountApi from "../app/AccountApi";
|
||||||
import PopupMenu from "./PopupMenu";
|
import PopupMenu from "./PopupMenu";
|
||||||
import { SubscriptionPopup } from "./SubscriptionPopup";
|
import { SubscriptionPopup } from "./SubscriptionPopup";
|
||||||
|
@ -86,7 +78,7 @@ const ActionBar = (props) => {
|
||||||
const SettingsIcons = (props) => {
|
const SettingsIcons = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [anchorEl, setAnchorEl] = useState(null);
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
const subscription = props.subscription;
|
const { subscription } = props;
|
||||||
|
|
||||||
const handleToggleMute = async () => {
|
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
|
|
@ -1,19 +1,17 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { createContext, Suspense, useContext, useEffect, useState } from "react";
|
import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react";
|
||||||
import Box from "@mui/material/Box";
|
import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress } from "@mui/material";
|
||||||
import { ThemeProvider } from "@mui/material/styles";
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
import CssBaseline from "@mui/material/CssBaseline";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import Toolbar from "@mui/material/Toolbar";
|
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
|
||||||
import { AllSubscriptions, SingleSubscription } from "./Notifications";
|
import { AllSubscriptions, SingleSubscription } from "./Notifications";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import Navigation from "./Navigation";
|
import Navigation from "./Navigation";
|
||||||
import ActionBar from "./ActionBar";
|
import ActionBar from "./ActionBar";
|
||||||
import notifier from "../app/Notifier";
|
import notifier from "../app/Notifier";
|
||||||
import Preferences from "./Preferences";
|
import Preferences from "./Preferences";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import userManager from "../app/UserManager";
|
import userManager from "../app/UserManager";
|
||||||
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
|
|
||||||
import { expandUrl } from "../app/utils";
|
import { expandUrl } from "../app/utils";
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
|
@ -21,7 +19,6 @@ import { useAccountListener, useBackgroundProcesses, useConnectionListeners } fr
|
||||||
import PublishDialog from "./PublishDialog";
|
import PublishDialog from "./PublishDialog";
|
||||||
import Messaging from "./Messaging";
|
import Messaging from "./Messaging";
|
||||||
import "./i18n"; // Translations!
|
import "./i18n"; // Translations!
|
||||||
import { Backdrop, CircularProgress } from "@mui/material";
|
|
||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
import Signup from "./Signup";
|
import Signup from "./Signup";
|
||||||
import Account from "./Account";
|
import Account from "./Account";
|
||||||
|
@ -30,11 +27,13 @@ export const AccountContext = createContext(null);
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [account, setAccount] = useState(null);
|
const [account, setAccount] = useState(null);
|
||||||
|
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Loader />}>
|
<Suspense fallback={<Loader />}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<AccountContext.Provider value={{ account, setAccount }}>
|
<AccountContext.Provider value={accountMemo}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
@ -56,6 +55,10 @@ const App = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateTitle = (newNotificationsCount) => {
|
||||||
|
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
|
||||||
|
};
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { account, setAccount } = useContext(AccountContext);
|
const { account, setAccount } = useContext(AccountContext);
|
||||||
|
@ -66,12 +69,11 @@ const Layout = () => {
|
||||||
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
||||||
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
|
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
|
||||||
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
||||||
const [selected] = (subscriptionsWithoutInternal || []).filter((s) => {
|
const [selected] = (subscriptionsWithoutInternal || []).filter(
|
||||||
return (
|
(s) =>
|
||||||
(params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) ||
|
(params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) ||
|
||||||
(config.base_url === s.baseUrl && params.topic === s.topic)
|
(config.base_url === s.baseUrl && params.topic === s.topic)
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
useConnectionListeners(account, subscriptions, users);
|
useConnectionListeners(account, subscriptions, users);
|
||||||
useAccountListener(setAccount);
|
useAccountListener(setAccount);
|
||||||
|
@ -95,7 +97,7 @@ const Layout = () => {
|
||||||
<Outlet
|
<Outlet
|
||||||
context={{
|
context={{
|
||||||
subscriptions: subscriptionsWithoutInternal,
|
subscriptions: subscriptionsWithoutInternal,
|
||||||
selected: selected,
|
selected,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Main>
|
</Main>
|
||||||
|
@ -104,8 +106,7 @@ const Layout = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Main = (props) => {
|
const Main = (props) => (
|
||||||
return (
|
|
||||||
<Box
|
<Box
|
||||||
id="main"
|
id="main"
|
||||||
component="main"
|
component="main"
|
||||||
|
@ -117,28 +118,23 @@ const Main = (props) => {
|
||||||
width: { sm: `calc(100% - ${Navigation.width}px)` },
|
width: { sm: `calc(100% - ${Navigation.width}px)` },
|
||||||
height: "100vh",
|
height: "100vh",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]),
|
backgroundColor: ({ palette }) => (palette.mode === "light" ? palette.grey[100] : palette.grey[900]),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const Loader = () => (
|
const Loader = () => (
|
||||||
<Backdrop
|
<Backdrop
|
||||||
open={true}
|
open
|
||||||
sx={{
|
sx={{
|
||||||
zIndex: 100000,
|
zIndex: 100000,
|
||||||
backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]),
|
backgroundColor: ({ palette }) => (palette.mode === "light" ? palette.grey[100] : palette.grey[900]),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CircularProgress color="success" disableShrink />
|
<CircularProgress color="success" disableShrink />
|
||||||
</Backdrop>
|
</Backdrop>
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateTitle = (newNotificationsCount) => {
|
|
||||||
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
export default App;
|
|
@ -1,16 +1,17 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Box from "@mui/material/Box";
|
import { Box } from "@mui/material";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import fileDocument from "../img/file-document.svg";
|
import fileDocument from "../img/file-document.svg";
|
||||||
import fileImage from "../img/file-image.svg";
|
import fileImage from "../img/file-image.svg";
|
||||||
import fileVideo from "../img/file-video.svg";
|
import fileVideo from "../img/file-video.svg";
|
||||||
import fileAudio from "../img/file-audio.svg";
|
import fileAudio from "../img/file-audio.svg";
|
||||||
import fileApp from "../img/file-app.svg";
|
import fileApp from "../img/file-app.svg";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
const AttachmentIcon = (props) => {
|
const AttachmentIcon = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const type = props.type;
|
const { type } = props;
|
||||||
let imageFile, imageLabel;
|
let imageFile;
|
||||||
|
let imageLabel;
|
||||||
if (!type) {
|
if (!type) {
|
||||||
imageFile = fileDocument;
|
imageFile = fileDocument;
|
||||||
imageLabel = t("notifications_attachment_file_image");
|
imageLabel = t("notifications_attachment_file_image");
|
|
@ -1,25 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
import { Avatar } from "@mui/material";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import logo from "../img/ntfy-filled.svg";
|
|
||||||
|
|
||||||
const AvatarBox = (props) => {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
height: "100vh",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />
|
|
||||||
{props.children}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AvatarBox;
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { Avatar, Box } from "@mui/material";
|
||||||
|
import logo from "../img/ntfy-filled.svg";
|
||||||
|
|
||||||
|
const AvatarBox = (props) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />
|
||||||
|
{props.children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AvatarBox;
|
|
@ -1,33 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import DialogContentText from "@mui/material/DialogContentText";
|
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
|
||||||
|
|
||||||
const DialogFooter = (props) => {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
paddingLeft: "24px",
|
|
||||||
paddingBottom: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContentText
|
|
||||||
component="div"
|
|
||||||
aria-live="polite"
|
|
||||||
sx={{
|
|
||||||
margin: "0px",
|
|
||||||
paddingTop: "12px",
|
|
||||||
paddingBottom: "4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.status}
|
|
||||||
</DialogContentText>
|
|
||||||
<DialogActions sx={{ paddingRight: 2 }}>{props.children}</DialogActions>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DialogFooter;
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { Box, DialogContentText, DialogActions } from "@mui/material";
|
||||||
|
|
||||||
|
const DialogFooter = (props) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingLeft: "24px",
|
||||||
|
paddingBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContentText
|
||||||
|
component="div"
|
||||||
|
aria-live="polite"
|
||||||
|
sx={{
|
||||||
|
margin: "0px",
|
||||||
|
paddingTop: "12px",
|
||||||
|
paddingBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.status}
|
||||||
|
</DialogContentText>
|
||||||
|
<DialogActions sx={{ paddingRight: 2 }}>{props.children}</DialogActions>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DialogFooter;
|
|
@ -1,15 +1,10 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import Typography from "@mui/material/Typography";
|
import { Typography, Box, TextField, ClickAwayListener, Fade, InputAdornment, styled, IconButton, Popper } from "@mui/material";
|
||||||
import { rawEmojis } from "../app/emojis";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import TextField from "@mui/material/TextField";
|
|
||||||
import { ClickAwayListener, Fade, InputAdornment, styled } from "@mui/material";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import { Close } from "@mui/icons-material";
|
import { Close } from "@mui/icons-material";
|
||||||
import Popper from "@mui/material/Popper";
|
|
||||||
import { splitNoEmpty } from "../app/utils";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { splitNoEmpty } from "../app/utils";
|
||||||
|
import { rawEmojis } from "../app/emojis";
|
||||||
|
|
||||||
// Create emoji list by category and create a search base (string with all search words)
|
// Create emoji list by category and create a search base (string with all search words)
|
||||||
//
|
//
|
||||||
|
@ -28,7 +23,7 @@ rawEmojis.forEach((emoji) => {
|
||||||
const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
|
const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
|
||||||
if (supportedEmoji) {
|
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 };
|
const emojiWithSearchBase = { ...emoji, searchBase };
|
||||||
emojisByCategory[emoji.category].push(emojiWithSearchBase);
|
emojisByCategory[emoji.category].push(emojiWithSearchBase);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -132,8 +127,10 @@ const Category = (props) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emojiMatches = (emoji, words) => words.length === 0 || words.some((word) => emoji.searchBase.includes(word));
|
||||||
|
|
||||||
const Emoji = (props) => {
|
const Emoji = (props) => {
|
||||||
const emoji = props.emoji;
|
const { emoji } = props;
|
||||||
const matches = emojiMatches(emoji, props.search);
|
const matches = emojiMatches(emoji, props.search);
|
||||||
const title = `${emoji.description} (${emoji.aliases[0]})`;
|
const title = `${emoji.description} (${emoji.aliases[0]})`;
|
||||||
return (
|
return (
|
||||||
|
@ -158,16 +155,4 @@ const EmojiDiv = styled("div")({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emojiMatches = (emoji, words) => {
|
|
||||||
if (words.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
for (const word of words) {
|
|
||||||
if (emoji.searchBase.indexOf(word) === -1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmojiPicker;
|
export default EmojiPicker;
|
|
@ -1,7 +1,6 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import StackTrace from "stacktrace-js";
|
import StackTrace from "stacktrace-js";
|
||||||
import { CircularProgress, Link } from "@mui/material";
|
import { CircularProgress, Link, Button } from "@mui/material";
|
||||||
import Button from "@mui/material/Button";
|
|
||||||
import { Trans, withTranslation } from "react-i18next";
|
import { Trans, withTranslation } from "react-i18next";
|
||||||
|
|
||||||
class ErrorBoundaryImpl extends React.Component {
|
class ErrorBoundaryImpl extends React.Component {
|
||||||
|
@ -46,9 +45,8 @@ class ErrorBoundaryImpl extends React.Component {
|
||||||
// Fetch additional info and a better stack trace
|
// Fetch additional info and a better stack trace
|
||||||
StackTrace.fromError(error).then((stack) => {
|
StackTrace.fromError(error).then((stack) => {
|
||||||
console.error("[ErrorBoundary] Stacktrace fetched", stack);
|
console.error("[ErrorBoundary] Stacktrace fetched", stack);
|
||||||
const niceStack =
|
const stackString = stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
|
||||||
`${error.toString()}\n` +
|
const niceStack = `${error.toString()}\n${stackString}`;
|
||||||
stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
|
|
||||||
this.setState({ niceStack });
|
this.setState({ niceStack });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -69,17 +67,6 @@ class ErrorBoundaryImpl extends React.Component {
|
||||||
navigator.clipboard.writeText(stack);
|
navigator.clipboard.writeText(stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.error) {
|
|
||||||
if (this.state.unsupportedIndexedDB) {
|
|
||||||
return this.renderUnsupportedIndexedDB();
|
|
||||||
} else {
|
|
||||||
return this.renderError();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderUnsupportedIndexedDB() {
|
renderUnsupportedIndexedDB() {
|
||||||
const { t } = this.props;
|
const { t } = this.props;
|
||||||
return (
|
return (
|
||||||
|
@ -131,6 +118,16 @@ class ErrorBoundaryImpl extends React.Component {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
if (this.state.unsupportedIndexedDB) {
|
||||||
|
return this.renderUnsupportedIndexedDB();
|
||||||
|
}
|
||||||
|
return this.renderError();
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t
|
const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t
|
|
@ -1,19 +1,14 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Typography from "@mui/material/Typography";
|
import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material";
|
||||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||||
import TextField from "@mui/material/TextField";
|
|
||||||
import Button from "@mui/material/Button";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import routes from "./routes";
|
|
||||||
import session from "../app/Session";
|
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import AvatarBox from "./AvatarBox";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import accountApi from "../app/AccountApi";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import { InputAdornment } from "@mui/material";
|
|
||||||
import { Visibility, VisibilityOff } from "@mui/icons-material";
|
import { Visibility, VisibilityOff } from "@mui/icons-material";
|
||||||
|
import accountApi from "../app/AccountApi";
|
||||||
|
import AvatarBox from "./AvatarBox";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import routes from "./routes";
|
||||||
import { UnauthorizedError } from "../app/errors";
|
import { UnauthorizedError } from "../app/errors";
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
|
@ -1,21 +1,18 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Navigation from "./Navigation";
|
import { Paper, IconButton, TextField, Portal, Snackbar } from "@mui/material";
|
||||||
import Paper from "@mui/material/Paper";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import TextField from "@mui/material/TextField";
|
|
||||||
import SendIcon from "@mui/icons-material/Send";
|
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";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import PublishDialog from "./PublishDialog";
|
||||||
|
import api from "../app/Api";
|
||||||
|
import Navigation from "./Navigation";
|
||||||
|
|
||||||
const Messaging = (props) => {
|
const Messaging = (props) => {
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [dialogKey, setDialogKey] = useState(0);
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
|
|
||||||
const dialogOpenMode = props.dialogOpenMode;
|
const { dialogOpenMode } = props;
|
||||||
const subscription = props.selected;
|
const subscription = props.selected;
|
||||||
|
|
||||||
const handleOpenDialogClick = () => {
|
const handleOpenDialogClick = () => {
|
||||||
|
@ -39,7 +36,7 @@ const Messaging = (props) => {
|
||||||
topic={subscription?.topic ?? ""}
|
topic={subscription?.topic ?? ""}
|
||||||
message={message}
|
message={message}
|
||||||
onClose={handleDialogClose}
|
onClose={handleDialogClose}
|
||||||
onDragEnter={() => props.onDialogOpenModeChange((prev) => (prev ? prev : PublishDialog.OPEN_MODE_DRAG))} // Only update if not already open
|
onDragEnter={() => props.onDialogOpenModeChange((prev) => prev || PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open
|
||||||
onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}
|
onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -48,7 +45,7 @@ const Messaging = (props) => {
|
||||||
|
|
||||||
const MessageBar = (props) => {
|
const MessageBar = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const subscription = props.subscription;
|
const { subscription } = props;
|
||||||
const [snackOpen, setSnackOpen] = useState(false);
|
const [snackOpen, setSnackOpen] = useState(false);
|
||||||
const handleSendClick = async () => {
|
const handleSendClick = async () => {
|
||||||
try {
|
try {
|
|
@ -1,38 +1,47 @@
|
||||||
import Drawer from "@mui/material/Drawer";
|
import {
|
||||||
|
Drawer,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Toolbar,
|
||||||
|
Divider,
|
||||||
|
List,
|
||||||
|
Alert,
|
||||||
|
AlertTitle,
|
||||||
|
Badge,
|
||||||
|
CircularProgress,
|
||||||
|
Link,
|
||||||
|
ListSubheader,
|
||||||
|
Portal,
|
||||||
|
Tooltip,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
IconButton,
|
||||||
|
} from "@mui/material";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import ListItemButton from "@mui/material/ListItemButton";
|
|
||||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
|
||||||
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
|
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
|
||||||
import Person from "@mui/icons-material/Person";
|
import Person from "@mui/icons-material/Person";
|
||||||
import ListItemText from "@mui/material/ListItemText";
|
|
||||||
import Toolbar from "@mui/material/Toolbar";
|
|
||||||
import Divider from "@mui/material/Divider";
|
|
||||||
import List from "@mui/material/List";
|
|
||||||
import SettingsIcon from "@mui/icons-material/Settings";
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
|
||||||
|
import ArticleIcon from "@mui/icons-material/Article";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import CelebrationIcon from "@mui/icons-material/Celebration";
|
||||||
import SubscribeDialog from "./SubscribeDialog";
|
import SubscribeDialog from "./SubscribeDialog";
|
||||||
import { Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Portal, Tooltip } from "@mui/material";
|
|
||||||
import Button from "@mui/material/Button";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
|
import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import { ConnectionState } from "../app/Connection";
|
import { ConnectionState } from "../app/Connection";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import notifier from "../app/Notifier";
|
import notifier from "../app/Notifier";
|
||||||
import config from "../app/config";
|
import config from "../app/config";
|
||||||
import ArticleIcon from "@mui/icons-material/Article";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import accountApi, { Permission, Role } from "../app/AccountApi";
|
import accountApi, { Permission, Role } from "../app/AccountApi";
|
||||||
import CelebrationIcon from "@mui/icons-material/Celebration";
|
|
||||||
import UpgradeDialog from "./UpgradeDialog";
|
import UpgradeDialog from "./UpgradeDialog";
|
||||||
import { AccountContext } from "./App";
|
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";
|
import { SubscriptionPopup } from "./SubscriptionPopup";
|
||||||
|
|
||||||
const navWidth = 280;
|
const navWidth = 280;
|
||||||
|
@ -85,6 +94,10 @@ const NavList = (props) => {
|
||||||
setSubscribeDialogKey((prev) => prev + 1);
|
setSubscribeDialogKey((prev) => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRequestNotificationPermission = () => {
|
||||||
|
notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubscribeSubmit = (subscription) => {
|
const handleSubscribeSubmit = (subscription) => {
|
||||||
console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
|
console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
|
||||||
handleSubscribeReset();
|
handleSubscribeReset();
|
||||||
|
@ -92,10 +105,6 @@ const NavList = (props) => {
|
||||||
handleRequestNotificationPermission();
|
handleRequestNotificationPermission();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRequestNotificationPermission = () => {
|
|
||||||
notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAccountClick = () => {
|
const handleAccountClick = () => {
|
||||||
accountApi.sync(); // Dangle!
|
accountApi.sync(); // Dangle!
|
||||||
navigate(routes.account);
|
navigate(routes.account);
|
||||||
|
@ -237,9 +246,7 @@ const UpgradeBanner = () => {
|
||||||
const SubscriptionList = (props) => {
|
const SubscriptionList = (props) => {
|
||||||
const sortedSubscriptions = props.subscriptions
|
const sortedSubscriptions = props.subscriptions
|
||||||
.filter((s) => !s.internal)
|
.filter((s) => !s.internal)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => (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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{sortedSubscriptions.map((subscription) => (
|
{sortedSubscriptions.map((subscription) => (
|
||||||
|
@ -258,7 +265,7 @@ const SubscriptionItem = (props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
|
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
|
||||||
|
|
||||||
const subscription = props.subscription;
|
const { subscription } = props;
|
||||||
const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
|
const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
|
||||||
const displayName = topicDisplayName(subscription);
|
const displayName = topicDisplayName(subscription);
|
||||||
const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName;
|
const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName;
|
|
@ -1,9 +1,29 @@
|
||||||
import Container from "@mui/material/Container";
|
import {
|
||||||
import { ButtonBase, CardActions, CardContent, CircularProgress, Fade, Link, Modal, Snackbar, Stack, Tooltip } from "@mui/material";
|
Container,
|
||||||
import Card from "@mui/material/Card";
|
ButtonBase,
|
||||||
import Typography from "@mui/material/Typography";
|
CardActions,
|
||||||
|
CardContent,
|
||||||
|
CircularProgress,
|
||||||
|
Fade,
|
||||||
|
Link,
|
||||||
|
Modal,
|
||||||
|
Snackbar,
|
||||||
|
Stack,
|
||||||
|
Tooltip,
|
||||||
|
Card,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
} from "@mui/material";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import CheckIcon from "@mui/icons-material/Check";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
import InfiniteScroll from "react-infinite-scroll-component";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { useOutletContext } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatMessage,
|
formatMessage,
|
||||||
|
@ -15,25 +35,23 @@ import {
|
||||||
topicShortUrl,
|
topicShortUrl,
|
||||||
unmatchedTags,
|
unmatchedTags,
|
||||||
} from "../app/utils";
|
} 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 { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import Button from "@mui/material/Button";
|
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import InfiniteScroll from "react-infinite-scroll-component";
|
|
||||||
import priority1 from "../img/priority-1.svg";
|
import priority1 from "../img/priority-1.svg";
|
||||||
import priority2 from "../img/priority-2.svg";
|
import priority2 from "../img/priority-2.svg";
|
||||||
import priority4 from "../img/priority-4.svg";
|
import priority4 from "../img/priority-4.svg";
|
||||||
import priority5 from "../img/priority-5.svg";
|
import priority5 from "../img/priority-5.svg";
|
||||||
import logoOutline from "../img/ntfy-outline.svg";
|
import logoOutline from "../img/ntfy-outline.svg";
|
||||||
import AttachmentIcon from "./AttachmentIcon";
|
import AttachmentIcon from "./AttachmentIcon";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
import { useOutletContext } from "react-router-dom";
|
|
||||||
import { useAutoSubscribe } from "./hooks";
|
import { useAutoSubscribe } from "./hooks";
|
||||||
|
|
||||||
|
const priorityFiles = {
|
||||||
|
1: priority1,
|
||||||
|
2: priority2,
|
||||||
|
4: priority4,
|
||||||
|
5: priority5,
|
||||||
|
};
|
||||||
|
|
||||||
export const AllSubscriptions = () => {
|
export const AllSubscriptions = () => {
|
||||||
const { subscriptions } = useOutletContext();
|
const { subscriptions } = useOutletContext();
|
||||||
if (!subscriptions) {
|
if (!subscriptions) {
|
||||||
|
@ -52,46 +70,50 @@ export const SingleSubscription = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const AllSubscriptionsList = (props) => {
|
const AllSubscriptionsList = (props) => {
|
||||||
const subscriptions = props.subscriptions;
|
const { subscriptions } = props;
|
||||||
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
|
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
|
||||||
if (notifications === null || notifications === undefined) {
|
if (notifications === null || notifications === undefined) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
} else if (subscriptions.length === 0) {
|
}
|
||||||
|
if (subscriptions.length === 0) {
|
||||||
return <NoSubscriptions />;
|
return <NoSubscriptions />;
|
||||||
} else if (notifications.length === 0) {
|
}
|
||||||
|
if (notifications.length === 0) {
|
||||||
return <NoNotificationsWithoutSubscription subscriptions={subscriptions} />;
|
return <NoNotificationsWithoutSubscription subscriptions={subscriptions} />;
|
||||||
}
|
}
|
||||||
return <NotificationList key="all" notifications={notifications} messageBar={false} />;
|
return <NotificationList key="all" notifications={notifications} messageBar={false} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SingleSubscriptionList = (props) => {
|
const SingleSubscriptionList = (props) => {
|
||||||
const subscription = props.subscription;
|
const { subscription } = props;
|
||||||
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
|
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
|
||||||
if (notifications === null || notifications === undefined) {
|
if (notifications === null || notifications === undefined) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
} else if (notifications.length === 0) {
|
}
|
||||||
|
if (notifications.length === 0) {
|
||||||
return <NoNotifications subscription={subscription} />;
|
return <NoNotifications subscription={subscription} />;
|
||||||
}
|
}
|
||||||
return <NotificationList id={subscription.id} notifications={notifications} messageBar={true} />;
|
return <NotificationList id={subscription.id} notifications={notifications} messageBar />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NotificationList = (props) => {
|
const NotificationList = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
const notifications = props.notifications;
|
const { notifications } = props;
|
||||||
const [snackOpen, setSnackOpen] = useState(false);
|
const [snackOpen, setSnackOpen] = useState(false);
|
||||||
const [maxCount, setMaxCount] = useState(pageSize);
|
const [maxCount, setMaxCount] = useState(pageSize);
|
||||||
const count = Math.min(notifications.length, maxCount);
|
const count = Math.min(notifications.length, maxCount);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(
|
||||||
return () => {
|
() => () => {
|
||||||
setMaxCount(pageSize);
|
setMaxCount(pageSize);
|
||||||
const main = document.getElementById("main");
|
const main = document.getElementById("main");
|
||||||
if (main) {
|
if (main) {
|
||||||
main.scrollTo(0, 0);
|
main.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
}, [props.id]);
|
[props.id]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
|
@ -127,10 +149,29 @@ const NotificationList = (props) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace links with <Link/> components; this is a combination of the genius function
|
||||||
|
* in [1] and the regex in [2].
|
||||||
|
*
|
||||||
|
* [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760
|
||||||
|
* [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);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <>{parts}</>;
|
||||||
|
};
|
||||||
|
|
||||||
const NotificationItem = (props) => {
|
const NotificationItem = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const notification = props.notification;
|
const { notification } = props;
|
||||||
const attachment = notification.attachment;
|
const { attachment } = notification;
|
||||||
const date = formatShortDateTime(notification.time);
|
const date = formatShortDateTime(notification.time);
|
||||||
const otherTags = unmatchedTags(notification.tags);
|
const otherTags = unmatchedTags(notification.tags);
|
||||||
const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
|
const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
|
||||||
|
@ -244,35 +285,9 @@ const NotificationItem = (props) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace links with <Link/> components; this is a combination of the genius function
|
|
||||||
* in [1] and the regex in [2].
|
|
||||||
*
|
|
||||||
* [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760
|
|
||||||
* [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);
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <>{parts}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const priorityFiles = {
|
|
||||||
1: priority1,
|
|
||||||
2: priority2,
|
|
||||||
4: priority4,
|
|
||||||
5: priority5,
|
|
||||||
};
|
|
||||||
|
|
||||||
const Attachment = (props) => {
|
const Attachment = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const attachment = props.attachment;
|
const { attachment } = props;
|
||||||
const expired = attachment.expires && attachment.expires < Date.now() / 1000;
|
const expired = attachment.expires && attachment.expires < Date.now() / 1000;
|
||||||
const expires = attachment.expires && attachment.expires > Date.now() / 1000;
|
const expires = attachment.expires && attachment.expires > Date.now() / 1000;
|
||||||
const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
|
const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
|
||||||
|
@ -402,66 +417,29 @@ const Image = (props) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserActions = (props) => {
|
const UserActions = (props) => (
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
{props.notification.actions.map((action) => (
|
{props.notification.actions.map((action) => (
|
||||||
<UserAction key={action.id} notification={props.notification} action={action} />
|
<UserAction key={action.id} notification={props.notification} action={action} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ACTION_PROGRESS_ONGOING = 1;
|
||||||
|
const ACTION_PROGRESS_SUCCESS = 2;
|
||||||
|
const ACTION_PROGRESS_FAILED = 3;
|
||||||
|
|
||||||
|
const ACTION_LABEL_SUFFIX = {
|
||||||
|
[ACTION_PROGRESS_ONGOING]: " …",
|
||||||
|
[ACTION_PROGRESS_SUCCESS]: " ✔",
|
||||||
|
[ACTION_PROGRESS_FAILED]: " ❌",
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserAction = (props) => {
|
const updateActionStatus = (notification, action, progress, error) => {
|
||||||
const { t } = useTranslation();
|
subscriptionManager.updateNotification({
|
||||||
const notification = props.notification;
|
...notification,
|
||||||
const action = props.action;
|
actions: notification.actions.map((a) => (a.id === action.id ? { ...a, progress, error } : a)),
|
||||||
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>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
} else if (action.action === "view") {
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
} else if (action.action === "http") {
|
|
||||||
const method = action.method ?? "POST";
|
|
||||||
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null; // Others
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const performHttpAction = async (notification, action) => {
|
const performHttpAction = async (notification, action) => {
|
||||||
|
@ -488,29 +466,63 @@ const performHttpAction = async (notification, action) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateActionStatus = (notification, action, progress, error) => {
|
const UserAction = (props) => {
|
||||||
notification.actions = notification.actions.map((a) => {
|
const { t } = useTranslation();
|
||||||
if (a.id !== action.id) {
|
const { notification } = props;
|
||||||
return a;
|
const { action } = 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>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return { ...a, progress: progress, error: error };
|
if (action.action === "view") {
|
||||||
});
|
return (
|
||||||
subscriptionManager.updateNotification(notification);
|
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
|
||||||
};
|
<Button
|
||||||
|
onClick={() => openUrl(action.url)}
|
||||||
const ACTION_PROGRESS_ONGOING = 1;
|
aria-label={t("notifications_actions_open_url_title", {
|
||||||
const ACTION_PROGRESS_SUCCESS = 2;
|
url: action.url,
|
||||||
const ACTION_PROGRESS_FAILED = 3;
|
})}
|
||||||
|
>
|
||||||
const ACTION_LABEL_SUFFIX = {
|
{action.label}
|
||||||
[ACTION_PROGRESS_ONGOING]: " …",
|
</Button>
|
||||||
[ACTION_PROGRESS_SUCCESS]: " ✔",
|
</Tooltip>
|
||||||
[ACTION_PROGRESS_FAILED]: " ❌",
|
);
|
||||||
|
}
|
||||||
|
if (action.action === "http") {
|
||||||
|
const method = action.method ?? "POST";
|
||||||
|
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={t("notifications_actions_http_request_title", {
|
||||||
|
method,
|
||||||
|
url: action.url,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => performHttpAction(notification, action)}
|
||||||
|
aria-label={t("notifications_actions_http_request_title", {
|
||||||
|
method,
|
||||||
|
url: action.url,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null; // Others
|
||||||
};
|
};
|
||||||
|
|
||||||
const NoNotifications = (props) => {
|
const NoNotifications = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
|
const topicShortUrlResolved = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
|
||||||
return (
|
return (
|
||||||
<VerticallyCenteredContainer maxWidth="xs">
|
<VerticallyCenteredContainer maxWidth="xs">
|
||||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||||
|
@ -521,7 +533,10 @@ const NoNotifications = (props) => {
|
||||||
<Paragraph>{t("notifications_none_for_topic_description")}</Paragraph>
|
<Paragraph>{t("notifications_none_for_topic_description")}</Paragraph>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
{t("notifications_example")}:<br />
|
{t("notifications_example")}:<br />
|
||||||
<tt>$ curl -d "Hi" {shortUrl}</tt>
|
<tt>
|
||||||
|
{'$ curl -d "Hi" '}
|
||||||
|
{topicShortUrlResolved}
|
||||||
|
</tt>
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
<ForMoreDetails />
|
<ForMoreDetails />
|
||||||
|
@ -533,7 +548,7 @@ const NoNotifications = (props) => {
|
||||||
const NoNotificationsWithoutSubscription = (props) => {
|
const NoNotificationsWithoutSubscription = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const subscription = props.subscriptions[0];
|
const subscription = props.subscriptions[0];
|
||||||
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
|
const topicShortUrlResolved = topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||||
return (
|
return (
|
||||||
<VerticallyCenteredContainer maxWidth="xs">
|
<VerticallyCenteredContainer maxWidth="xs">
|
||||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||||
|
@ -544,7 +559,10 @@ const NoNotificationsWithoutSubscription = (props) => {
|
||||||
<Paragraph>{t("notifications_none_for_any_description")}</Paragraph>
|
<Paragraph>{t("notifications_none_for_any_description")}</Paragraph>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
{t("notifications_example")}:<br />
|
{t("notifications_example")}:<br />
|
||||||
<tt>$ curl -d "Hi" {shortUrl}</tt>
|
<tt>
|
||||||
|
{'$ curl -d "Hi" '}
|
||||||
|
{topicShortUrlResolved}
|
||||||
|
</tt>
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
<ForMoreDetails />
|
<ForMoreDetails />
|
||||||
|
@ -574,8 +592,7 @@ const NoSubscriptions = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ForMoreDetails = () => {
|
const ForMoreDetails = () => (
|
||||||
return (
|
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="notifications_more_details"
|
i18nKey="notifications_more_details"
|
||||||
components={{
|
components={{
|
||||||
|
@ -584,7 +601,6 @@ const ForMoreDetails = () => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const Loading = () => {
|
const Loading = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
|
@ -37,8 +37,8 @@ const PopupMenu = (props) => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
transformOrigin={{ horizontal: horizontal, vertical: "top" }}
|
transformOrigin={{ horizontal, vertical: "top" }}
|
||||||
anchorOrigin={{ horizontal: horizontal, vertical: "bottom" }}
|
anchorOrigin={{ horizontal, vertical: "bottom" }}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Menu>
|
</Menu>
|
|
@ -1,8 +1,6 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
export const PrefGroup = (props) => {
|
export const PrefGroup = (props) => <div role="table">{props.children}</div>;
|
||||||
return <div role="table">{props.children}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Pref = (props) => {
|
export const Pref = (props) => {
|
||||||
const justifyContent = props.alignTop ? "normal" : "center";
|
const justifyContent = props.alignTop ? "normal" : "center";
|
||||||
|
@ -24,7 +22,7 @@ export const Pref = (props) => {
|
||||||
flex: "1 0 40%",
|
flex: "1 0 40%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: justifyContent,
|
justifyContent,
|
||||||
paddingRight: "30px",
|
paddingRight: "30px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -44,7 +42,7 @@ export const Pref = (props) => {
|
||||||
flex: "1 0 calc(60% - 50px)",
|
flex: "1 0 calc(60% - 50px)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: justifyContent,
|
justifyContent,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
|
@ -15,43 +15,55 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Container,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
} from "@mui/material";
|
} 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 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";
|
|
||||||
import Card from "@mui/material/Card";
|
|
||||||
import Button from "@mui/material/Button";
|
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Info } from "@mui/icons-material";
|
||||||
|
import { useOutletContext } from "react-router-dom";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import Dialog from "@mui/material/Dialog";
|
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
|
||||||
import userManager from "../app/UserManager";
|
import userManager from "../app/UserManager";
|
||||||
import { playSound, shuffle, sounds, validUrl } from "../app/utils";
|
import { playSound, shuffle, sounds, validUrl } from "../app/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import accountApi, { Permission, Role } from "../app/AccountApi";
|
import accountApi, { Permission, Role } from "../app/AccountApi";
|
||||||
import { Pref, PrefGroup } from "./Pref";
|
import { Pref, PrefGroup } from "./Pref";
|
||||||
import { Info } from "@mui/icons-material";
|
|
||||||
import { AccountContext } from "./App";
|
import { AccountContext } from "./App";
|
||||||
import { useOutletContext } from "react-router-dom";
|
import { Paragraph } from "./styles";
|
||||||
|
import prefs from "../app/Prefs";
|
||||||
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
|
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
|
||||||
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
|
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
|
||||||
import { UnauthorizedError } from "../app/errors";
|
import { UnauthorizedError } from "../app/errors";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
|
||||||
import { subscribeTopic } from "./SubscribeDialog";
|
import { subscribeTopic } from "./SubscribeDialog";
|
||||||
|
|
||||||
const Preferences = () => {
|
const maybeUpdateAccountSettings = async (payload) => {
|
||||||
return (
|
if (!session.exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await accountApi.updateSettings(payload);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Preferences] Error updating account settings`, e);
|
||||||
|
if (e instanceof UnauthorizedError) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Preferences = () => (
|
||||||
<Container maxWidth="md" sx={{ marginTop: 3, marginBottom: 3 }}>
|
<Container maxWidth="md" sx={{ marginTop: 3, marginBottom: 3 }}>
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Notifications />
|
<Notifications />
|
||||||
|
@ -61,7 +73,6 @@ const Preferences = () => {
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const Notifications = () => {
|
const Notifications = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -107,7 +118,7 @@ const Sound = () => {
|
||||||
<div style={{ display: "flex", width: "100%" }}>
|
<div style={{ display: "flex", width: "100%" }}>
|
||||||
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
|
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
|
||||||
<Select value={sound} onChange={handleChange} aria-labelledby={labelId}>
|
<Select value={sound} onChange={handleChange} aria-labelledby={labelId}>
|
||||||
<MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem>
|
<MenuItem value="none">{t("prefs_notifications_sound_no_sound")}</MenuItem>
|
||||||
{Object.entries(sounds).map((s) => (
|
{Object.entries(sounds).map((s) => (
|
||||||
<MenuItem key={s[0]} value={s[0]}>
|
<MenuItem key={s[0]} value={s[0]}>
|
||||||
{s[1].label}
|
{s[1].label}
|
||||||
|
@ -183,10 +194,12 @@ const DeleteAfter = () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (deleteAfter === null || deleteAfter === undefined) {
|
if (deleteAfter === null || deleteAfter === undefined) {
|
||||||
// !deleteAfter will not work with "0"
|
// !deleteAfter will not work with "0"
|
||||||
return null; // While loading
|
return null; // While loading
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = (() => {
|
const description = (() => {
|
||||||
switch (deleteAfter) {
|
switch (deleteAfter) {
|
||||||
case 0:
|
case 0:
|
||||||
|
@ -199,8 +212,11 @@ const DeleteAfter = () => {
|
||||||
return t("prefs_notifications_delete_after_one_week_description");
|
return t("prefs_notifications_delete_after_one_week_description");
|
||||||
case 2592000:
|
case 2592000:
|
||||||
return t("prefs_notifications_delete_after_one_month_description");
|
return t("prefs_notifications_delete_after_one_month_description");
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
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 }}>
|
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||||
|
@ -245,7 +261,7 @@ const Users = () => {
|
||||||
</Typography>
|
</Typography>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
{t("prefs_users_description")}
|
{t("prefs_users_description")}
|
||||||
{session.exists() && <>{" " + t("prefs_users_description_no_sync")}</>}
|
{session.exists() && <>{` ${t("prefs_users_description_no_sync")}`}</>}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
{users?.length > 0 && <UserTable users={users} />}
|
{users?.length > 0 && <UserTable users={users} />}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
@ -371,9 +387,9 @@ const UserDialog = (props) => {
|
||||||
})();
|
})();
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
props.onSubmit({
|
props.onSubmit({
|
||||||
baseUrl: baseUrl,
|
baseUrl,
|
||||||
username: username,
|
username,
|
||||||
password: password,
|
password,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -479,7 +495,7 @@ const Language = () => {
|
||||||
const showFlags = !navigator.userAgent.includes("Windows");
|
const showFlags = !navigator.userAgent.includes("Windows");
|
||||||
let title = t("prefs_appearance_language_title");
|
let title = t("prefs_appearance_language_title");
|
||||||
if (showFlags) {
|
if (showFlags) {
|
||||||
title += " " + randomFlags.join(" ");
|
title += ` ${randomFlags.join(" ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = async (ev) => {
|
const handleChange = async (ev) => {
|
||||||
|
@ -676,18 +692,4 @@ const ReservationsTable = (props) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeUpdateAccountSettings = async (payload) => {
|
|
||||||
if (!session.exists()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await accountApi.updateSettings(payload);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[Preferences] Error updating account settings`, e);
|
|
||||||
if (e instanceof UnauthorizedError) {
|
|
||||||
session.resetAndRedirect(routes.login);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Preferences;
|
export default Preferences;
|
|
@ -1,30 +1,40 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useContext, useEffect, useRef, useState } from "react";
|
import { useContext, useEffect, useRef, useState } from "react";
|
||||||
import theme from "./theme";
|
import {
|
||||||
import { Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, Tooltip, useMediaQuery } from "@mui/material";
|
Checkbox,
|
||||||
import TextField from "@mui/material/TextField";
|
Chip,
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
InputLabel,
|
||||||
|
Link,
|
||||||
|
Select,
|
||||||
|
Tooltip,
|
||||||
|
useMediaQuery,
|
||||||
|
TextField,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
MenuItem,
|
||||||
|
Box,
|
||||||
|
} from "@mui/material";
|
||||||
|
import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
|
||||||
|
import { Close } from "@mui/icons-material";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import priority1 from "../img/priority-1.svg";
|
import priority1 from "../img/priority-1.svg";
|
||||||
import priority2 from "../img/priority-2.svg";
|
import priority2 from "../img/priority-2.svg";
|
||||||
import priority3 from "../img/priority-3.svg";
|
import priority3 from "../img/priority-3.svg";
|
||||||
import priority4 from "../img/priority-4.svg";
|
import priority4 from "../img/priority-4.svg";
|
||||||
import priority5 from "../img/priority-5.svg";
|
import priority5 from "../img/priority-5.svg";
|
||||||
import Dialog from "@mui/material/Dialog";
|
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
|
||||||
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 { 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 AttachmentIcon from "./AttachmentIcon";
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
import api from "../app/Api";
|
import api from "../app/Api";
|
||||||
import userManager from "../app/UserManager";
|
import userManager from "../app/UserManager";
|
||||||
import EmojiPicker from "./EmojiPicker";
|
import EmojiPicker from "./EmojiPicker";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import theme from "./theme";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import accountApi from "../app/AccountApi";
|
import accountApi from "../app/AccountApi";
|
||||||
|
@ -137,7 +147,7 @@ const PublishDialog = (props) => {
|
||||||
if (attachFile && message.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 || message;
|
||||||
try {
|
try {
|
||||||
const user = await userManager.get(baseUrl);
|
const user = await userManager.get(baseUrl);
|
||||||
const headers = maybeWithAuth({}, user);
|
const headers = maybeWithAuth({}, user);
|
||||||
|
@ -171,32 +181,33 @@ const PublishDialog = (props) => {
|
||||||
|
|
||||||
const checkAttachmentLimits = async (file) => {
|
const checkAttachmentLimits = async (file) => {
|
||||||
try {
|
try {
|
||||||
const account = await accountApi.get();
|
const apiAccount = await accountApi.get();
|
||||||
const fileSizeLimit = account.limits.attachment_file_size ?? 0;
|
const fileSizeLimit = apiAccount.limits.attachment_file_size ?? 0;
|
||||||
const remainingBytes = account.stats.attachment_total_size_remaining;
|
const remainingBytes = apiAccount.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;
|
const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
|
||||||
if (fileSizeLimitReached && quotaReached) {
|
if (fileSizeLimitReached && quotaReached) {
|
||||||
return setAttachFileError(
|
setAttachFileError(
|
||||||
t("publish_dialog_attachment_limits_file_and_quota_reached", {
|
t("publish_dialog_attachment_limits_file_and_quota_reached", {
|
||||||
fileSizeLimit: formatBytes(fileSizeLimit),
|
fileSizeLimit: formatBytes(fileSizeLimit),
|
||||||
remainingBytes: formatBytes(remainingBytes),
|
remainingBytes: formatBytes(remainingBytes),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else if (fileSizeLimitReached) {
|
} else if (fileSizeLimitReached) {
|
||||||
return setAttachFileError(
|
setAttachFileError(
|
||||||
t("publish_dialog_attachment_limits_file_reached", {
|
t("publish_dialog_attachment_limits_file_reached", {
|
||||||
fileSizeLimit: formatBytes(fileSizeLimit),
|
fileSizeLimit: formatBytes(fileSizeLimit),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else if (quotaReached) {
|
} else if (quotaReached) {
|
||||||
return setAttachFileError(
|
setAttachFileError(
|
||||||
t("publish_dialog_attachment_limits_quota_reached", {
|
t("publish_dialog_attachment_limits_quota_reached", {
|
||||||
remainingBytes: formatBytes(remainingBytes),
|
remainingBytes: formatBytes(remainingBytes),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
setAttachFileError("");
|
setAttachFileError("");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[PublishDialog] Retrieving attachment limits failed`, e);
|
console.log(`[PublishDialog] Retrieving attachment limits failed`, e);
|
||||||
if (e instanceof UnauthorizedError) {
|
if (e instanceof UnauthorizedError) {
|
||||||
|
@ -211,6 +222,13 @@ const PublishDialog = (props) => {
|
||||||
attachFileInput.current.click();
|
attachFileInput.current.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateAttachFile = async (file) => {
|
||||||
|
setAttachFile(file);
|
||||||
|
setFilename(file.name);
|
||||||
|
props.onResetOpenMode();
|
||||||
|
await checkAttachmentLimits(file);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAttachFileChanged = async (ev) => {
|
const handleAttachFileChanged = async (ev) => {
|
||||||
await updateAttachFile(ev.target.files[0]);
|
await updateAttachFile(ev.target.files[0]);
|
||||||
};
|
};
|
||||||
|
@ -221,13 +239,6 @@ const PublishDialog = (props) => {
|
||||||
await updateAttachFile(ev.dataTransfer.files[0]);
|
await updateAttachFile(ev.dataTransfer.files[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateAttachFile = async (file) => {
|
|
||||||
setAttachFile(file);
|
|
||||||
setFilename(file.name);
|
|
||||||
props.onResetOpenMode();
|
|
||||||
await checkAttachmentLimits(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAttachFileDragLeave = () => {
|
const handleAttachFileDragLeave = () => {
|
||||||
setDropZone(false);
|
setDropZone(false);
|
||||||
if (props.openMode === PublishDialog.OPEN_MODE_DRAG) {
|
if (props.openMode === PublishDialog.OPEN_MODE_DRAG) {
|
||||||
|
@ -240,7 +251,7 @@ const PublishDialog = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmojiPick = (emoji) => {
|
const handleEmojiPick = (emoji) => {
|
||||||
setTags((tags) => (tags.trim() ? `${tags.trim()}, ${emoji}` : emoji));
|
setTags((prevTags) => (prevTags.trim() ? `${prevTags.trim()}, ${emoji}` : emoji));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmojiClose = () => {
|
const handleEmojiClose = () => {
|
||||||
|
@ -372,23 +383,23 @@ const PublishDialog = (props) => {
|
||||||
"aria-label": t("publish_dialog_priority_label"),
|
"aria-label": t("publish_dialog_priority_label"),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[5, 4, 3, 2, 1].map((priority) => (
|
{[5, 4, 3, 2, 1].map((p) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={`priorityMenuItem${priority}`}
|
key={`priorityMenuItem${p}`}
|
||||||
value={priority}
|
value={p}
|
||||||
aria-label={t("notifications_priority_x", {
|
aria-label={t("notifications_priority_x", {
|
||||||
priority: priority,
|
priority: p,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center" }}>
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
<img
|
<img
|
||||||
src={priorities[priority].file}
|
src={priorities[p].file}
|
||||||
style={{ marginRight: "8px" }}
|
style={{ marginRight: "8px" }}
|
||||||
alt={t("notifications_priority_x", {
|
alt={t("notifications_priority_x", {
|
||||||
priority: priority,
|
priority: p,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<div>{priorities[priority].label}</div>
|
<div>{priorities[p].label}</div>
|
||||||
</div>
|
</div>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
|
@ -466,8 +477,8 @@ const PublishDialog = (props) => {
|
||||||
"aria-label": t("publish_dialog_call_label"),
|
"aria-label": t("publish_dialog_call_label"),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{account?.phone_numbers?.map((phoneNumber, i) => (
|
{account?.phone_numbers?.map((phoneNumber) => (
|
||||||
<MenuItem key={`phoneNumberMenuItem${i}`} value={phoneNumber} aria-label={phoneNumber}>
|
<MenuItem key={phoneNumber} value={phoneNumber} aria-label={phoneNumber}>
|
||||||
{t("publish_dialog_call_item", { number: phoneNumber })}
|
{t("publish_dialog_call_item", { number: phoneNumber })}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
|
@ -533,7 +544,7 @@ const PublishDialog = (props) => {
|
||||||
/>
|
/>
|
||||||
</ClosableRow>
|
</ClosableRow>
|
||||||
)}
|
)}
|
||||||
<input type="file" ref={attachFileInput} onChange={handleAttachFileChanged} style={{ display: "none" }} aria-hidden={true} />
|
<input type="file" ref={attachFileInput} onChange={handleAttachFileChanged} style={{ display: "none" }} aria-hidden />
|
||||||
{showAttachFile && (
|
{showAttachFile && (
|
||||||
<AttachmentBox
|
<AttachmentBox
|
||||||
file={attachFile}
|
file={attachFile}
|
||||||
|
@ -707,16 +718,14 @@ const PublishDialog = (props) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Row = (props) => {
|
const Row = (props) => (
|
||||||
return (
|
|
||||||
<div style={{ display: "flex" }} role="row">
|
<div style={{ display: "flex" }} role="row">
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const ClosableRow = (props) => {
|
const ClosableRow = (props) => {
|
||||||
const closable = props.hasOwnProperty("closable") ? props.closable : true;
|
const closable = props.closable !== undefined ? props.closable : true;
|
||||||
return (
|
return (
|
||||||
<Row>
|
<Row>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
@ -748,7 +757,7 @@ const DialogIconButton = (props) => {
|
||||||
|
|
||||||
const AttachmentBox = (props) => {
|
const AttachmentBox = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const file = props.file;
|
const { file } = props;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography variant="body1" sx={{ marginTop: 2 }}>
|
<Typography variant="body1" sx={{ marginTop: 2 }}>
|
||||||
|
@ -811,13 +820,7 @@ const ExpandingTextField = (props) => {
|
||||||
}, [props.value]);
|
}, [props.value]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography
|
<Typography ref={invisibleFieldRef} component="span" variant={props.variant} aria-hidden sx={{ position: "absolute", left: "-200%" }}>
|
||||||
ref={invisibleFieldRef}
|
|
||||||
component="span"
|
|
||||||
variant={props.variant}
|
|
||||||
aria-hidden={true}
|
|
||||||
sx={{ position: "absolute", left: "-200%" }}
|
|
||||||
>
|
|
||||||
{props.value}
|
{props.value}
|
||||||
</Typography>
|
</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
|
@ -846,6 +849,7 @@ const DropArea = (props) => {
|
||||||
// This is where we could disallow certain files to be dragged in.
|
// This is where we could disallow certain files to be dragged in.
|
||||||
// For now we allow all files.
|
// For now we allow all files.
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
ev.dataTransfer.dropEffect = "copy";
|
ev.dataTransfer.dropEffect = "copy";
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
};
|
};
|
|
@ -1,24 +1,29 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Button from "@mui/material/Button";
|
import {
|
||||||
import TextField from "@mui/material/TextField";
|
Button,
|
||||||
import Dialog from "@mui/material/Dialog";
|
TextField,
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
Dialog,
|
||||||
import DialogContentText from "@mui/material/DialogContentText";
|
DialogContent,
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
DialogContentText,
|
||||||
import { Alert, FormControl, Select, useMediaQuery } from "@mui/material";
|
DialogTitle,
|
||||||
|
Alert,
|
||||||
|
FormControl,
|
||||||
|
Select,
|
||||||
|
useMediaQuery,
|
||||||
|
MenuItem,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Check, DeleteForever } from "@mui/icons-material";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import { validTopic } from "../app/utils";
|
import { validTopic } from "../app/utils";
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import accountApi, { Permission } from "../app/AccountApi";
|
import accountApi, { Permission } from "../app/AccountApi";
|
||||||
import ReserveTopicSelect from "./ReserveTopicSelect";
|
import ReserveTopicSelect from "./ReserveTopicSelect";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
|
||||||
import ListItemText from "@mui/material/ListItemText";
|
|
||||||
import { Check, DeleteForever } from "@mui/icons-material";
|
|
||||||
import { TopicReservedError, UnauthorizedError } from "../app/errors";
|
import { TopicReservedError, UnauthorizedError } from "../app/errors";
|
||||||
|
|
||||||
export const ReserveAddDialog = (props) => {
|
export const ReserveAddDialog = (props) => {
|
||||||
|
@ -164,7 +169,7 @@ export const ReserveDeleteDialog = (props) => {
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary={t("reservation_delete_dialog_action_keep_title")} />
|
<ListItemText primary={t("reservation_delete_dialog_action_keep_title")} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem value={true}>
|
<MenuItem value>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<DeleteForever />
|
<DeleteForever />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
|
@ -1,22 +1,14 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Lock, Public } from "@mui/icons-material";
|
import { Lock, Public } from "@mui/icons-material";
|
||||||
import Box from "@mui/material/Box";
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
export const PermissionReadWrite = React.forwardRef((props, ref) => {
|
export const PermissionReadWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} ref={ref} {...props} />);
|
||||||
return <PermissionInternal icon={Public} ref={ref} {...props} />;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PermissionDenyAll = React.forwardRef((props, ref) => {
|
export const PermissionDenyAll = React.forwardRef((props, ref) => <PermissionInternal icon={Lock} ref={ref} {...props} />);
|
||||||
return <PermissionInternal icon={Lock} ref={ref} {...props} />;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PermissionRead = React.forwardRef((props, ref) => {
|
export const PermissionRead = React.forwardRef((props, ref) => <PermissionInternal icon={Public} text="R" ref={ref} {...props} />);
|
||||||
return <PermissionInternal icon={Public} text="R" ref={ref} {...props} />;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PermissionWrite = React.forwardRef((props, ref) => {
|
export const PermissionWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} text="W" ref={ref} {...props} />);
|
||||||
return <PermissionInternal icon={Public} text="W" ref={ref} {...props} />;
|
|
||||||
});
|
|
||||||
|
|
||||||
const PermissionInternal = React.forwardRef((props, ref) => {
|
const PermissionInternal = React.forwardRef((props, ref) => {
|
||||||
const size = props.size ?? "medium";
|
const size = props.size ?? "medium";
|
|
@ -1,9 +1,6 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FormControl, Select } from "@mui/material";
|
import { FormControl, Select, MenuItem, ListItemIcon, ListItemText } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import { Permission } from "../app/AccountApi";
|
||||||
|
|
|
@ -1,19 +1,14 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import TextField from "@mui/material/TextField";
|
import { TextField, Button, Box, Typography, InputAdornment, IconButton } from "@mui/material";
|
||||||
import Button from "@mui/material/Button";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import routes from "./routes";
|
|
||||||
import session from "../app/Session";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import AvatarBox from "./AvatarBox";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||||
import accountApi from "../app/AccountApi";
|
|
||||||
import { InputAdornment } from "@mui/material";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import { Visibility, VisibilityOff } from "@mui/icons-material";
|
import { Visibility, VisibilityOff } from "@mui/icons-material";
|
||||||
|
import accountApi from "../app/AccountApi";
|
||||||
|
import AvatarBox from "./AvatarBox";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import routes from "./routes";
|
||||||
import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors";
|
import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors";
|
||||||
|
|
||||||
const Signup = () => {
|
const Signup = () => {
|
|
@ -1,12 +1,19 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import Button from "@mui/material/Button";
|
import {
|
||||||
import TextField from "@mui/material/TextField";
|
Button,
|
||||||
import Dialog from "@mui/material/Dialog";
|
TextField,
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
Dialog,
|
||||||
import DialogContentText from "@mui/material/DialogContentText";
|
DialogContent,
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
DialogContentText,
|
||||||
import { Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery } from "@mui/material";
|
DialogTitle,
|
||||||
|
Autocomplete,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel,
|
||||||
|
FormGroup,
|
||||||
|
useMediaQuery,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import api from "../app/Api";
|
import api from "../app/Api";
|
||||||
import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils";
|
import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils";
|
||||||
|
@ -14,7 +21,6 @@ import userManager from "../app/UserManager";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import poller from "../app/Poller";
|
import poller from "../app/Poller";
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import accountApi, { Permission, Role } from "../app/AccountApi";
|
import accountApi, { Permission, Role } from "../app/AccountApi";
|
||||||
|
@ -25,6 +31,21 @@ import { ReserveLimitChip } from "./SubscriptionPopup";
|
||||||
|
|
||||||
const publicBaseUrl = "https://ntfy.sh";
|
const publicBaseUrl = "https://ntfy.sh";
|
||||||
|
|
||||||
|
export const subscribeTopic = async (baseUrl, topic) => {
|
||||||
|
const subscription = await subscriptionManager.add(baseUrl, topic);
|
||||||
|
if (session.exists()) {
|
||||||
|
try {
|
||||||
|
await accountApi.addSubscription(baseUrl, topic);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
|
||||||
|
if (e instanceof UnauthorizedError) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subscription;
|
||||||
|
};
|
||||||
|
|
||||||
const SubscribeDialog = (props) => {
|
const SubscribeDialog = (props) => {
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
|
@ -33,7 +54,7 @@ const SubscribeDialog = (props) => {
|
||||||
|
|
||||||
const handleSuccess = async () => {
|
const handleSuccess = async () => {
|
||||||
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
|
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
|
||||||
const actualBaseUrl = baseUrl ? baseUrl : config.base_url;
|
const actualBaseUrl = baseUrl || config.base_url;
|
||||||
const subscription = await subscribeTopic(actualBaseUrl, topic);
|
const subscription = await subscribeTopic(actualBaseUrl, topic);
|
||||||
poller.pollInBackground(subscription); // Dangle!
|
poller.pollInBackground(subscription); // Dangle!
|
||||||
props.onSuccess(subscription);
|
props.onSuccess(subscription);
|
||||||
|
@ -66,7 +87,7 @@ const SubscribePage = (props) => {
|
||||||
const [anotherServerVisible, setAnotherServerVisible] = useState(false);
|
const [anotherServerVisible, setAnotherServerVisible] = useState(false);
|
||||||
const [everyone, setEveryone] = useState(Permission.DENY_ALL);
|
const [everyone, setEveryone] = useState(Permission.DENY_ALL);
|
||||||
const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url;
|
const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url;
|
||||||
const topic = props.topic;
|
const { topic } = props;
|
||||||
const existingTopicUrls = props.subscriptions.map((s) => topicUrl(s.baseUrl, s.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(
|
const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])).filter(
|
||||||
(s) => s !== config.base_url
|
(s) => s !== config.base_url
|
||||||
|
@ -86,15 +107,14 @@ const SubscribePage = (props) => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setError(
|
setError(
|
||||||
t("subscribe_dialog_error_user_not_authorized", {
|
t("subscribe_dialog_error_user_not_authorized", {
|
||||||
username: username,
|
username,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} else {
|
}
|
||||||
props.onNeedsLogin();
|
props.onNeedsLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Reserve topic (if requested)
|
// Reserve topic (if requested)
|
||||||
if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) {
|
if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) {
|
||||||
|
@ -125,10 +145,9 @@ const SubscribePage = (props) => {
|
||||||
if (anotherServerVisible) {
|
if (anotherServerVisible) {
|
||||||
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
|
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
|
||||||
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
|
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;
|
return validTopic(topic) && !isExistingTopicUrl;
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const updateBaseUrl = (ev, newVal) => {
|
const updateBaseUrl = (ev, newVal) => {
|
||||||
|
@ -242,14 +261,14 @@ const LoginPage = (props) => {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = 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 { topic } = props;
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
const user = { baseUrl, username, password };
|
const user = { baseUrl, username, password };
|
||||||
const success = await api.topicAuth(baseUrl, topic, user);
|
const success = await api.topicAuth(baseUrl, topic, user);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
||||||
setError(t("subscribe_dialog_error_user_not_authorized", { username: username }));
|
setError(t("subscribe_dialog_error_user_not_authorized", { username }));
|
||||||
return;
|
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}`);
|
||||||
|
@ -298,19 +317,4 @@ const LoginPage = (props) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const subscribeTopic = async (baseUrl, topic) => {
|
|
||||||
const subscription = await subscriptionManager.add(baseUrl, topic);
|
|
||||||
if (session.exists()) {
|
|
||||||
try {
|
|
||||||
await accountApi.addSubscription(baseUrl, topic);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
|
|
||||||
if (e instanceof UnauthorizedError) {
|
|
||||||
session.resetAndRedirect(routes.login);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return subscription;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SubscribeDialog;
|
export default SubscribeDialog;
|
|
@ -1,26 +1,32 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import Button from "@mui/material/Button";
|
import {
|
||||||
import TextField from "@mui/material/TextField";
|
Button,
|
||||||
import Dialog from "@mui/material/Dialog";
|
TextField,
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
Dialog,
|
||||||
import DialogContentText from "@mui/material/DialogContentText";
|
DialogContent,
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
DialogContentText,
|
||||||
import { Chip, InputAdornment, Portal, Snackbar, useMediaQuery } from "@mui/material";
|
DialogTitle,
|
||||||
|
Chip,
|
||||||
|
InputAdornment,
|
||||||
|
Portal,
|
||||||
|
Snackbar,
|
||||||
|
useMediaQuery,
|
||||||
|
MenuItem,
|
||||||
|
IconButton,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Clear } from "@mui/icons-material";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import accountApi, { Role } from "../app/AccountApi";
|
import accountApi, { Role } from "../app/AccountApi";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import PopupMenu from "./PopupMenu";
|
import PopupMenu from "./PopupMenu";
|
||||||
import { formatShortDateTime, shuffle } from "../app/utils";
|
import { formatShortDateTime, shuffle } from "../app/utils";
|
||||||
import api from "../app/Api";
|
import api from "../app/Api";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import { Clear } from "@mui/icons-material";
|
|
||||||
import { AccountContext } from "./App";
|
import { AccountContext } from "./App";
|
||||||
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
|
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
|
||||||
import { UnauthorizedError } from "../app/errors";
|
import { UnauthorizedError } from "../app/errors";
|
||||||
|
@ -34,7 +40,7 @@ export const SubscriptionPopup = (props) => {
|
||||||
const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false);
|
const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false);
|
||||||
const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false);
|
const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false);
|
||||||
const [showPublishError, setShowPublishError] = useState(false);
|
const [showPublishError, setShowPublishError] = useState(false);
|
||||||
const subscription = props.subscription;
|
const { subscription } = props;
|
||||||
const placement = props.placement ?? "left";
|
const placement = props.placement ?? "left";
|
||||||
const reservations = account?.reservations || [];
|
const reservations = account?.reservations || [];
|
||||||
|
|
||||||
|
@ -64,8 +70,8 @@ export const SubscriptionPopup = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendTestMessage = async () => {
|
const handleSendTestMessage = async () => {
|
||||||
const baseUrl = props.subscription.baseUrl;
|
const { baseUrl } = props.subscription;
|
||||||
const topic = props.subscription.topic;
|
const { topic } = props.subscription;
|
||||||
const tags = shuffle([
|
const tags = shuffle([
|
||||||
"grinning",
|
"grinning",
|
||||||
"octopus",
|
"octopus",
|
||||||
|
@ -110,9 +116,9 @@ export const SubscriptionPopup = (props) => {
|
||||||
])[0];
|
])[0];
|
||||||
try {
|
try {
|
||||||
await api.publish(baseUrl, topic, message, {
|
await api.publish(baseUrl, topic, message, {
|
||||||
title: title,
|
title,
|
||||||
priority: priority,
|
priority,
|
||||||
tags: tags,
|
tags,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[SubscriptionPopup] Error publishing message`, e);
|
console.log(`[SubscriptionPopup] Error publishing message`, e);
|
||||||
|
@ -201,7 +207,7 @@ export const SubscriptionPopup = (props) => {
|
||||||
|
|
||||||
const DisplayNameDialog = (props) => {
|
const DisplayNameDialog = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const subscription = props.subscription;
|
const { subscription } = props;
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
|
const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
@ -265,9 +271,11 @@ export const ReserveLimitChip = () => {
|
||||||
const { account } = useContext(AccountContext);
|
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 <></>;
|
return <></>;
|
||||||
} else if (config.enable_payments) {
|
}
|
||||||
|
if (config.enable_payments) {
|
||||||
return account?.limits.reservations > 0 ? <LimitReachedChip /> : <ProChip />;
|
return account?.limits.reservations > 0 ? <LimitReachedChip /> : <ProChip />;
|
||||||
} else if (account) {
|
}
|
||||||
|
if (account) {
|
||||||
return <LimitReachedChip />;
|
return <LimitReachedChip />;
|
||||||
}
|
}
|
||||||
return <></>;
|
return <></>;
|
||||||
|
@ -290,11 +298,9 @@ const LimitReachedChip = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProChip = () => {
|
export const ProChip = () => (
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<Chip
|
<Chip
|
||||||
label={"ntfy Pro"}
|
label="ntfy Pro"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
sx={{
|
sx={{
|
||||||
|
@ -306,4 +312,3 @@ export const ProChip = () => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
|
|
@ -1,28 +1,64 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useContext, useEffect, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import Dialog from "@mui/material/Dialog";
|
import {
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
Dialog,
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
DialogContent,
|
||||||
import { Alert, CardActionArea, CardContent, Chip, Link, ListItem, Switch, useMediaQuery } from "@mui/material";
|
DialogTitle,
|
||||||
import theme from "./theme";
|
Alert,
|
||||||
import Button from "@mui/material/Button";
|
CardActionArea,
|
||||||
import accountApi, { SubscriptionInterval } from "../app/AccountApi";
|
CardContent,
|
||||||
import session from "../app/Session";
|
Chip,
|
||||||
import routes from "./routes";
|
Link,
|
||||||
import Card from "@mui/material/Card";
|
ListItem,
|
||||||
import Typography from "@mui/material/Typography";
|
Switch,
|
||||||
import { AccountContext } from "./App";
|
useMediaQuery,
|
||||||
import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils";
|
Button,
|
||||||
|
Card,
|
||||||
|
Typography,
|
||||||
|
List,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Box,
|
||||||
|
DialogContentText,
|
||||||
|
DialogActions,
|
||||||
|
} from "@mui/material";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import List from "@mui/material/List";
|
|
||||||
import { Check, Close } from "@mui/icons-material";
|
import { Check, Close } from "@mui/icons-material";
|
||||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
|
||||||
import ListItemText from "@mui/material/ListItemText";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import { UnauthorizedError } from "../app/errors";
|
import { UnauthorizedError } from "../app/errors";
|
||||||
import DialogContentText from "@mui/material/DialogContentText";
|
import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils";
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
import { AccountContext } from "./App";
|
||||||
|
import routes from "./routes";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import accountApi, { SubscriptionInterval } from "../app/AccountApi";
|
||||||
|
import theme from "./theme";
|
||||||
|
|
||||||
|
const Feature = (props) => <FeatureItem feature>{props.children}</FeatureItem>;
|
||||||
|
|
||||||
|
const NoFeature = (props) => <FeatureItem feature={false}>{props.children}</FeatureItem>;
|
||||||
|
|
||||||
|
const FeatureItem = (props) => (
|
||||||
|
<ListItem disableGutters sx={{ m: 0, p: 0 }}>
|
||||||
|
<ListItemIcon sx={{ minWidth: "24px" }}>
|
||||||
|
{props.feature && <Check fontSize="small" sx={{ color: "#338574" }} />}
|
||||||
|
{!props.feature && <Close fontSize="small" sx={{ color: "gray" }} />}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText sx={{ mt: "2px", mb: "2px" }} primary={<Typography variant="body1">{props.children}</Typography>} />
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Action = {
|
||||||
|
REDIRECT_SIGNUP: 1,
|
||||||
|
CREATE_SUBSCRIPTION: 2,
|
||||||
|
UPDATE_SUBSCRIPTION: 3,
|
||||||
|
CANCEL_SUBSCRIPTION: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Banner = {
|
||||||
|
CANCEL_WARNING: 1,
|
||||||
|
PRORATION_INFO: 2,
|
||||||
|
RESERVATIONS_WARNING: 3,
|
||||||
|
};
|
||||||
|
|
||||||
const UpgradeDialog = (props) => {
|
const UpgradeDialog = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -52,7 +88,9 @@ const UpgradeDialog = (props) => {
|
||||||
const currentTierCode = currentTier?.code; // May be undefined
|
const currentTierCode = currentTier?.code; // May be undefined
|
||||||
|
|
||||||
// Figure out buttons, labels and the submit action
|
// Figure out buttons, labels and the submit action
|
||||||
let submitAction, submitButtonLabel, banner;
|
let submitAction;
|
||||||
|
let submitButtonLabel;
|
||||||
|
let banner;
|
||||||
if (!account) {
|
if (!account) {
|
||||||
submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
|
submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
|
||||||
submitAction = Action.REDIRECT_SIGNUP;
|
submitAction = Action.REDIRECT_SIGNUP;
|
||||||
|
@ -112,18 +150,18 @@ const UpgradeDialog = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Figure out discount
|
// Figure out discount
|
||||||
let discount = 0,
|
let discount = 0;
|
||||||
upto = false;
|
let upto = false;
|
||||||
if (newTier?.prices) {
|
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 {
|
} else {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const t of tiers) {
|
for (const tier of tiers) {
|
||||||
if (t.prices) {
|
if (tier.prices) {
|
||||||
const tierDiscount = Math.round(((t.prices.month * 12) / t.prices.year - 1) * 100);
|
const tierDiscount = Math.round(((tier.prices.month * 12) / tier.prices.year - 1) * 100);
|
||||||
if (tierDiscount > discount) {
|
if (tierDiscount > discount) {
|
||||||
discount = tierDiscount;
|
discount = tierDiscount;
|
||||||
n++;
|
n += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -157,8 +195,8 @@ const UpgradeDialog = (props) => {
|
||||||
<Chip
|
<Chip
|
||||||
label={
|
label={
|
||||||
upto
|
upto
|
||||||
? t("account_upgrade_dialog_interval_yearly_discount_save_up_to", { discount: discount })
|
? t("account_upgrade_dialog_interval_yearly_discount_save_up_to", { discount })
|
||||||
: t("account_upgrade_dialog_interval_yearly_discount_save", { discount: discount })
|
: t("account_upgrade_dialog_interval_yearly_discount_save", { discount })
|
||||||
}
|
}
|
||||||
color="primary"
|
color="primary"
|
||||||
size="small"
|
size="small"
|
||||||
|
@ -208,7 +246,7 @@ const UpgradeDialog = (props) => {
|
||||||
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
|
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="account_upgrade_dialog_reservations_warning"
|
i18nKey="account_upgrade_dialog_reservations_warning"
|
||||||
count={account?.reservations.length - newTier?.limits.reservations}
|
count={(account?.reservations.length ?? 0) - (newTier?.limits.reservations ?? 0)}
|
||||||
components={{
|
components={{
|
||||||
Link: <NavLink to={routes.settings} />,
|
Link: <NavLink to={routes.settings} />,
|
||||||
}}
|
}}
|
||||||
|
@ -269,9 +307,11 @@ const UpgradeDialog = (props) => {
|
||||||
|
|
||||||
const TierCard = (props) => {
|
const TierCard = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tier = props.tier;
|
const { tier } = props;
|
||||||
|
|
||||||
let cardStyle, labelStyle, labelText;
|
let cardStyle;
|
||||||
|
let labelStyle;
|
||||||
|
let labelText;
|
||||||
if (props.selected) {
|
if (props.selected) {
|
||||||
cardStyle = { background: "#eee", border: "3px solid #338574" };
|
cardStyle = { background: "#eee", border: "3px solid #338574" };
|
||||||
labelStyle = { background: "#338574", color: "white" };
|
labelStyle = { background: "#338574", color: "white" };
|
||||||
|
@ -392,37 +432,4 @@ const TierCard = (props) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Feature = (props) => {
|
|
||||||
return <FeatureItem feature={true}>{props.children}</FeatureItem>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const NoFeature = (props) => {
|
|
||||||
return <FeatureItem feature={false}>{props.children}</FeatureItem>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FeatureItem = (props) => {
|
|
||||||
return (
|
|
||||||
<ListItem disableGutters sx={{ m: 0, p: 0 }}>
|
|
||||||
<ListItemIcon sx={{ minWidth: "24px" }}>
|
|
||||||
{props.feature && <Check fontSize="small" sx={{ color: "#338574" }} />}
|
|
||||||
{!props.feature && <Close fontSize="small" sx={{ color: "gray" }} />}
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText sx={{ mt: "2px", mb: "2px" }} primary={<Typography variant="body1">{props.children}</Typography>} />
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Action = {
|
|
||||||
REDIRECT_SIGNUP: 1,
|
|
||||||
CREATE_SUBSCRIPTION: 2,
|
|
||||||
UPDATE_SUBSCRIPTION: 3,
|
|
||||||
CANCEL_SUBSCRIPTION: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
const Banner = {
|
|
||||||
CANCEL_WARNING: 1,
|
|
||||||
PRORATION_INFO: 2,
|
|
||||||
RESERVATIONS_WARNING: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UpgradeDialog;
|
export default UpgradeDialog;
|
|
@ -22,15 +22,6 @@ export const useConnectionListeners = (account, subscriptions, users) => {
|
||||||
// Register listeners for incoming messages, and connection state changes
|
// 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) {
|
|
||||||
await handleInternalMessage(message);
|
|
||||||
} else {
|
|
||||||
await handleNotification(subscriptionId, message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInternalMessage = async (message) => {
|
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 {
|
try {
|
||||||
|
@ -53,15 +44,26 @@ export const useConnectionListeners = (account, subscriptions, users) => {
|
||||||
await notifier.notify(subscriptionId, notification, defaultClickAction);
|
await notifier.notify(subscriptionId, notification, defaultClickAction);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMessage = async (subscriptionId, message) => {
|
||||||
|
const subscription = await subscriptionManager.get(subscriptionId);
|
||||||
|
if (subscription.internal) {
|
||||||
|
await handleInternalMessage(message);
|
||||||
|
} else {
|
||||||
|
await handleNotification(subscriptionId, message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
connectionManager.registerStateListener(subscriptionManager.updateState);
|
connectionManager.registerStateListener(subscriptionManager.updateState);
|
||||||
connectionManager.registerMessageListener(handleMessage);
|
connectionManager.registerMessageListener(handleMessage);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
connectionManager.resetStateListener();
|
connectionManager.resetStateListener();
|
||||||
connectionManager.resetMessageListener();
|
connectionManager.resetMessageListener();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// We have to disable dep checking for "navigate". This is fine, it never changes.
|
// We have to disable dep checking for "navigate". This is fine, it never changes.
|
||||||
// eslint-disable-next-line
|
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import Typography from "@mui/material/Typography";
|
import { Typography, Container, Backdrop, styled } from "@mui/material";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import Container from "@mui/material/Container";
|
|
||||||
import { Backdrop, styled } from "@mui/material";
|
|
||||||
|
|
||||||
export const Paragraph = styled(Typography)({
|
export const Paragraph = styled(Typography)({
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
build: {
|
||||||
|
outDir: "build",
|
||||||
|
assetsDir: "static/media",
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
plugins: [react()],
|
||||||
|
}));
|
Loading…
Reference in New Issue