mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9105dad09 | ||
|
|
5765be7892 | ||
|
|
7a99bfc717 | ||
|
|
156ad4ae92 | ||
|
|
a6dc691672 | ||
|
|
9114d1e996 | ||
|
|
55d871aeb2 | ||
|
|
a5318a4312 | ||
|
|
f77d299aa3 | ||
|
|
af66cc3ddc | ||
|
|
a47eef5006 | ||
|
|
0ac7cbd4fe | ||
|
|
72ac95148b | ||
|
|
817a0ece7e | ||
|
|
e617c5f453 | ||
|
|
a04a12eb5f | ||
|
|
46799db753 | ||
|
|
5e32f05302 | ||
|
|
e3889746c4 | ||
|
|
df6f1f3ee1 | ||
|
|
047a1258f1 | ||
|
|
2b1f2d6b9a | ||
|
|
87e9dc9da4 | ||
|
|
5d96888f0e | ||
|
|
65e409e9e4 | ||
|
|
8022401728 | ||
|
|
6d86574a38 | ||
|
|
6a4f68897a | ||
|
|
b0411e5c92 | ||
|
|
c5e5be0746 | ||
|
|
ab2966e70f | ||
|
|
3a960b1b89 | ||
|
|
ad45f70fd1 | ||
|
|
4b070546b5 | ||
|
|
e6a201bc11 | ||
|
|
8327047b71 | ||
|
|
c44575d7a1 | ||
|
|
f121f2ba8d | ||
|
|
0c313906ef | ||
|
|
ab1e170a20 | ||
|
|
fb75a65885 | ||
|
|
74240328dc | ||
|
|
3bfb9f334b | ||
|
|
7b37f2a3eb | ||
|
|
d5b13a925e | ||
|
|
50ac2c1925 | ||
|
|
cac3b2986a | ||
|
|
19af0b65cc | ||
|
|
5834f667b2 | ||
|
|
fd0f0657b9 | ||
|
|
a0775701c1 | ||
|
|
d824f1a11a | ||
|
|
2d7faac0ea | ||
|
|
737163ba59 | ||
|
|
26bc28ae24 | ||
|
|
88fff8264b | ||
|
|
9cc6124f18 | ||
|
|
fb89b87efb |
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout ntfy code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
-
|
||||
name: Checkout docs pages code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
repository: binwiederhier/ntfy-docs.github.io
|
||||
path: build/ntfy-docs.github.io
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -5,7 +5,9 @@ PIP := pip3
|
||||
VERSION := $(shell git describe --tag)
|
||||
COMMIT := $(shell git rev-parse --short HEAD)
|
||||
|
||||
.PHONY:
|
||||
# FORCE is an always-out-of-date target with no recipe; listing it as a prerequisite
|
||||
# forces that target's recipe to run every time (the classic "FORCE target" idiom).
|
||||
FORCE:
|
||||
|
||||
help:
|
||||
@echo "Typical commands (more see below):"
|
||||
@@ -43,6 +45,7 @@ help:
|
||||
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||
@echo " make web-build - Actually build the web app"
|
||||
@echo " make web-lint - Run eslint on the web app"
|
||||
@echo " make web-test - Run vitest unit tests for the web app"
|
||||
@echo " make web-fmt - Run prettier on the web app"
|
||||
@echo " make web-fmt-check - Run prettier on the web app, but don't change anything"
|
||||
@echo
|
||||
@@ -52,7 +55,9 @@ help:
|
||||
@echo " make docs-build - Actually build the documentation"
|
||||
@echo
|
||||
@echo "Test/check:"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make test - Run all tests (Go + web)"
|
||||
@echo " make cli-test - Run Go tests only"
|
||||
@echo " make web-test - Run web app tests only"
|
||||
@echo " make race - Run tests with -race flag"
|
||||
@echo " make coverage - Run tests and show coverage"
|
||||
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
||||
@@ -82,7 +87,7 @@ help:
|
||||
|
||||
# Building everything
|
||||
|
||||
clean: .PHONY
|
||||
clean: FORCE
|
||||
rm -rf dist build server/docs server/site
|
||||
|
||||
build: web docs cli
|
||||
@@ -119,7 +124,7 @@ build-deps-ubuntu:
|
||||
|
||||
docs: docs-deps docs-build
|
||||
|
||||
docs-venv: .PHONY
|
||||
docs-venv: FORCE
|
||||
$(PYTHON) -m venv ./venv
|
||||
|
||||
docs-build: docs-venv
|
||||
@@ -128,7 +133,7 @@ docs-build: docs-venv
|
||||
docs-deps: docs-venv
|
||||
(. venv/bin/activate && $(PIP) install -r requirements.txt)
|
||||
|
||||
docs-deps-update: .PHONY
|
||||
docs-deps-update: FORCE
|
||||
(. venv/bin/activate && $(PIP) install -r requirements.txt --upgrade)
|
||||
|
||||
|
||||
@@ -163,6 +168,9 @@ web-fmt-check:
|
||||
web-lint:
|
||||
cd web && $(NPM) run lint
|
||||
|
||||
web-test:
|
||||
cd web && $(NPM) run test
|
||||
|
||||
# Main server/client build
|
||||
|
||||
cli: cli-deps
|
||||
@@ -269,13 +277,17 @@ check: test web-fmt-check fmt-check vet web-lint lint staticcheck
|
||||
|
||||
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck
|
||||
|
||||
test: .PHONY
|
||||
test: cli-test web-test
|
||||
|
||||
testv: cli-testv web-test
|
||||
|
||||
cli-test: FORCE
|
||||
go test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
testv: .PHONY
|
||||
cli-testv: FORCE
|
||||
go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
race: .PHONY
|
||||
race: FORCE
|
||||
go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
coverage:
|
||||
@@ -307,7 +319,7 @@ lint:
|
||||
which golint || go install golang.org/x/lint/golint@latest
|
||||
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
|
||||
|
||||
staticcheck: .PHONY
|
||||
staticcheck: FORCE
|
||||
rm -rf build/staticcheck
|
||||
which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
mkdir -p build/staticcheck
|
||||
|
||||
@@ -90,6 +90,15 @@ func (d *DB) ReadOnly() *sql.DB {
|
||||
return d.primary.DB
|
||||
}
|
||||
|
||||
// MarkReplicasHealthyForTest immediately marks all configured replicas as healthy, bypassing the
|
||||
// async health-check loop's initial delay. It exists so tests can deterministically route
|
||||
// ReadOnly() to a replica without waiting; it is not used in production code.
|
||||
func (d *DB) MarkReplicasHealthyForTest() {
|
||||
for _, r := range d.replicas {
|
||||
r.healthy.Store(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the primary database and all replicas, and stops the health-check goroutine.
|
||||
func (d *DB) Close() error {
|
||||
d.cancel()
|
||||
|
||||
+38
-38
@@ -34,37 +34,37 @@ as a service starting at boot time.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.25.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.25.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.25.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.25.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.25.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.25.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.25.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.25.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
@@ -118,25 +118,25 @@ Install the ntfy server service script:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.25.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.25.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.25.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.25.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
@@ -204,7 +204,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -212,7 +212,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -220,7 +220,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -228,7 +228,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -238,28 +238,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -301,18 +301,18 @@ pkg install go-ntfy
|
||||
|
||||
## macOS
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_darwin_all.tar.gz),
|
||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||
|
||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_darwin_all.tar.gz > ntfy_2.24.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.24.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_darwin_all.tar.gz > ntfy_2.25.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.25.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.24.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.25.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -333,7 +333,7 @@ brew install ntfy
|
||||
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
||||
To install, you can either
|
||||
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_windows_amd64.zip),
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_windows_amd64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
|
||||
|
||||
|
||||
@@ -189,6 +189,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS)
|
||||
- [SIA-Server](https://github.com/ZebMcKayhan/SIA-Server) - A light weight, self-hosted notification Server for Honywell Galaxy Flex alarm systems (Python)
|
||||
- [zabbix-ntfy](https://github.com/torgrimt/zabbix-ntfy) - Zabbix server Mediatype to add support for ntfy.sh services
|
||||
- [Rubix Notify](https://wordpress.org/plugins/rubix-notify) - WordPress Integration with ntfy (PHP + React).
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
|
||||
+39
-5
@@ -2815,16 +2815,20 @@ Here's an example of a dead man's switch that sends an alert if the script stops
|
||||
### Canceling scheduled notifications
|
||||
|
||||
You can cancel a scheduled message before it is delivered by sending a DELETE request to the
|
||||
`/<topic>/<sequence_id>` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the
|
||||
scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers.
|
||||
`/<topic>/<sequence_id>` endpoint, just like [deleting notifications](#deleting-notifications). Alternatively, you can send a `GET`
|
||||
request to `/<topic>/<sequence_id>/delete`. This will remove the scheduled message from the server so it will never be delivered,
|
||||
and emit a `message_delete` event to any subscribers.
|
||||
|
||||
=== "Command line (curl)"
|
||||
```bash
|
||||
# Schedule a reminder for 2 hours from now
|
||||
curl -H "In: 2h" -d "Take a break!" ntfy.sh/mytopic/break-reminder
|
||||
|
||||
# Changed your mind? Cancel the scheduled message
|
||||
# Changed your mind? Cancel the scheduled message via DELETE
|
||||
curl -X DELETE ntfy.sh/mytopic/break-reminder
|
||||
|
||||
# Or cancel it via GET
|
||||
curl ntfy.sh/mytopic/break-reminder/delete
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
@@ -4143,26 +4147,41 @@ field the response. A sequence of updates may look like this (first example from
|
||||
### Clearing notifications
|
||||
Clearing a notification means **marking it as read and dismissing it from the notification drawer**.
|
||||
|
||||
To do this, send a PUT request to the `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/read` as an alias).
|
||||
To do this, send a `PUT` request to the `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/read` as an alias).
|
||||
This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status
|
||||
and dismiss the notification.
|
||||
|
||||
Alternatively, if your client has limited HTTP support, you can send a `GET` request to the same endpoints:
|
||||
`GET /<topic>/<sequence_id>/clear` or `GET /<topic>/<sequence_id>/read`.
|
||||
|
||||
=== "Command line (curl)"
|
||||
```bash
|
||||
# Via PUT method
|
||||
curl -X PUT ntfy.sh/mytopic/my-download-123/clear
|
||||
|
||||
# Via GET method
|
||||
curl ntfy.sh/mytopic/my-download-123/clear
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
PUT /mytopic/my-download-123/clear HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
|
||||
# Or using GET
|
||||
GET /mytopic/my-download-123/clear HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
// Via PUT method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123/clear', {
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
// Via GET method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123/clear');
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
@@ -4197,25 +4216,40 @@ An example response from the server with the `message_clear` event may look like
|
||||
### Deleting notifications
|
||||
Deleting a notification means **removing it from the notification drawer and from the client's database**.
|
||||
|
||||
To do this, send a DELETE request to the `/<topic>/<sequence_id>` endpoint. This will emit a `message_delete` event
|
||||
To do this, send a `DELETE` request to the `/<topic>/<sequence_id>` endpoint. This will emit a `message_delete` event
|
||||
that is used by the clients (web app and Android app) to remove the notification entirely.
|
||||
|
||||
Alternatively, if your client has limited HTTP support (e.g. webhooks or IoT devices), you can also delete a message by sending
|
||||
a `GET` request to `/<topic>/<sequence_id>/delete`.
|
||||
|
||||
=== "Command line (curl)"
|
||||
```bash
|
||||
# Via DELETE method
|
||||
curl -X DELETE ntfy.sh/mytopic/my-download-123
|
||||
|
||||
# Via GET method
|
||||
curl ntfy.sh/mytopic/my-download-123/delete
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
DELETE /mytopic/my-download-123 HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
|
||||
# Or using GET
|
||||
GET /mytopic/my-download-123/delete HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
// Via DELETE method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
// Via GET method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123/delete');
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
+44
-22
@@ -4,15 +4,50 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Current stable releases
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|--------------|
|
||||
| ntfy server | v2.24.0 | June 4, 2026 |
|
||||
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
|
||||
| ntfy iOS app | v1.7.0 | May 30, 2026 |
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|---------------|
|
||||
| ntfy server | v2.25.0 | June 24, 2026 |
|
||||
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
|
||||
| ntfy iOS app | v1.7.0 | May 30, 2026 |
|
||||
|
||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||
|
||||
### ntfy server v2.24.0
|
||||
## ntfy server v2.25.0
|
||||
Released June 24, 2026
|
||||
|
||||
This release adds **password reset** via email, and reworks email verification to use durable,
|
||||
link-based magic links (replacing the old in-memory 6-digit codes). Email stays optional at
|
||||
signup; a user can reset their password only once they have a verified "primary" (recovery)
|
||||
email.
|
||||
|
||||
All of this work is probably not useful for self-hosters, but it hopefully will be useful for me,
|
||||
since I do have to reset accounts on a regular basis.
|
||||
|
||||
**Security issues:**
|
||||
|
||||
* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add password reset via emailed magic link, with a "Forgot password" link on the login page and a `ntfy user reset-pass` CLI command for admins
|
||||
* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" email (used for account recovery and as the `X-Email: yes` target) with verified/unverified state in the account UI
|
||||
* You can now clear/read messages and delete messages with a GET request ([#1771](https://github.com/binwiederhier/ntfy/issues/1771), thanks to [@lemmi](https://github.com/lemmi) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
* Add a reload button to the web app's action bar when running as an installed PWA, which clears the service worker caches and hard-refreshes the app
|
||||
* Add a "Back to app" link to the web app's login, signup, and password-reset pages (alongside the existing links), which previously had no way back to the app
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address
|
||||
* Grant users full access to their own sync topic (`st_...`) so cross-device subscription sync works under `auth-default-access: deny-all` ([#733](https://github.com/binwiederhier/ntfy/issues/733), [#1795](https://github.com/binwiederhier/ntfy/pull/1795), thanks to [@lmorchard](https://github.com/lmorchard) for the contribution)
|
||||
* Support HTTP (non-TLS) S3-compatible endpoints by preserving the endpoint scheme, e.g. for a local MinIO instance ([#1794](https://github.com/binwiederhier/ntfy/pull/1794), [#1734](https://github.com/binwiederhier/ntfy/issues/1734), thanks to [@sskender](https://github.com/sskender) for the contribution, and [@Kernald](https://github.com/Kernald) for reporting)
|
||||
* Stop silently stripping spaces from passwords while typing in the web app's login, signup, and password-reset forms ([#1246](https://github.com/binwiederhier/ntfy/issues/1246), thanks to [@aldem](https://github.com/aldem) for reporting)
|
||||
* Update web app dependencies, including major-version upgrades to Vite (6 -> 8, now Rolldown-based), Material UI (5 -> 9), and Dexie (3 -> 4) ([#1800](https://github.com/binwiederhier/ntfy/pull/1800), [#1764](https://github.com/binwiederhier/ntfy/pull/1764), [#1767](https://github.com/binwiederhier/ntfy/pull/1767), [#1762](https://github.com/binwiederhier/ntfy/pull/1762), [#1766](https://github.com/binwiederhier/ntfy/pull/1766), [#1765](https://github.com/binwiederhier/ntfy/pull/1765), thanks Dependabot)
|
||||
* Play notification sounds in the web app even when the Notification API is unavailable, e.g. over plain HTTP or in browsers without notification support ([#1772](https://github.com/binwiederhier/ntfy/pull/1772), thanks to [@mitya12342](https://github.com/mitya12342) for the contribution)
|
||||
* Stop escaping `<`, `>`, and `&` as `\u003c`/`\u003e`/`\u0026` in JSON responses ([#1511](https://github.com/binwiederhier/ntfy/issues/1511), [#1512](https://github.com/binwiederhier/ntfy/pull/1512), thanks to [@wunter8](https://github.com/wunter8) for the contribution)
|
||||
* Fix the web app navbar not reflecting a topic reservation (lock icon, and "Reserve topic" -> "Change reservation"/"Remove reservation" menu) until a page reload, by persisting reservation and display-name changes onto already-subscribed topics during account sync
|
||||
* Reduce the web app's initial bundle size by ~300 KB (~50 KB gzipped) by lazy-loading the emoji picker dataset and the Markdown renderer, and by importing Material UI icons individually
|
||||
|
||||
## ntfy server v2.24.0
|
||||
Released June 4, 2026
|
||||
|
||||
The main feature for this release is an in-memory ACL cache (`auth-access-cache`) that can help bring down the read load
|
||||
@@ -1948,25 +1983,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy server v2.25.0 (UNRELEASED)
|
||||
|
||||
This release adds **password reset** via email, and reworks email verification to use durable,
|
||||
link-based magic links (replacing the old in-memory 6-digit codes). Email stays optional at
|
||||
signup; a user can reset their password only once they have a verified "primary" (recovery)
|
||||
email.
|
||||
|
||||
All of this work is probably not useful for self-hosters, but it hopefully will be useful for me,
|
||||
since I do have to reset emails on a regular basis.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add password reset via emailed magic link, with a "Forgot password" link on the login page and a `ntfy user reset-pass` CLI command for admins
|
||||
* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" email (used for account recovery and as the `X-Email: yes` target) with verified/unverified state in the account UI
|
||||
### ntfy server v2.26.x (UNRELEASED)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG
|
||||
* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address
|
||||
* Web app: Smooth transitions and loading animation, remove flickering
|
||||
* Web app: `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
|
||||
|
||||
### ntfy Android v1.25.x (UNRELEASED)
|
||||
|
||||
|
||||
@@ -4,22 +4,22 @@ go 1.25.8
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.22.0 // indirect
|
||||
cloud.google.com/go/storage v1.62.2 // indirect
|
||||
cloud.google.com/go/storage v1.62.3 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/mattn/go-sqlite3 v1.14.47
|
||||
github.com/olebedev/when v1.1.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/api v0.283.0
|
||||
google.golang.org/api v0.286.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -34,8 +34,8 @@ require (
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/stripe/stripe-go/v74 v74.30.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/text v0.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -48,9 +48,9 @@ require (
|
||||
cloud.google.com/go/longrunning v1.0.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.29.0 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
|
||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
@@ -60,7 +60,7 @@ require (
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
@@ -79,10 +79,10 @@ require (
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.68.1 // indirect
|
||||
github.com/prometheus/common v0.69.0 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
@@ -94,11 +94,11 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -18,8 +18,8 @@ cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMh
|
||||
cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
|
||||
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
|
||||
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
|
||||
cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4=
|
||||
cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
|
||||
cloud.google.com/go/storage v1.62.3 h1:SZq1t23NCI+e96dH77Dg3PEfsNNEjqO8zE5AnD8gVD0=
|
||||
cloud.google.com/go/storage v1.62.3/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
|
||||
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
|
||||
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
|
||||
firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU=
|
||||
@@ -28,14 +28,14 @@ github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
|
||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
@@ -66,8 +66,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
@@ -120,8 +120,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
||||
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -139,16 +139,16 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY=
|
||||
github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
|
||||
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
|
||||
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4=
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E=
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
@@ -195,8 +195,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -211,8 +211,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -222,8 +222,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -236,8 +236,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -247,8 +247,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -260,8 +260,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -274,16 +274,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik=
|
||||
google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM=
|
||||
google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI=
|
||||
google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
|
||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE=
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY=
|
||||
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
|
||||
@@ -22,6 +22,7 @@ func TestParseURL_Success(t *testing.T) {
|
||||
require.Equal(t, "us-east-1", cfg.Region)
|
||||
require.Equal(t, "AKID", cfg.AccessKey)
|
||||
require.Equal(t, "SECRET", cfg.SecretKey)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "s3.us-east-1.amazonaws.com", cfg.Endpoint)
|
||||
require.False(t, cfg.PathStyle)
|
||||
}
|
||||
@@ -38,6 +39,7 @@ func TestParseURL_WithEndpoint(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "my-bucket", cfg.Bucket)
|
||||
require.Equal(t, "prefix", cfg.Prefix)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "s3.example.com", cfg.Endpoint)
|
||||
require.True(t, cfg.PathStyle)
|
||||
}
|
||||
@@ -45,10 +47,32 @@ func TestParseURL_WithEndpoint(t *testing.T) {
|
||||
func TestParseURL_EndpointHTTP(t *testing.T) {
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=http://localhost:9000")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "http", cfg.Scheme)
|
||||
require.Equal(t, "localhost:9000", cfg.Endpoint)
|
||||
require.True(t, cfg.PathStyle)
|
||||
}
|
||||
|
||||
func TestParseURL_EndpointNoScheme(t *testing.T) {
|
||||
// A bare host:port endpoint (no scheme) must default to https for backward compatibility.
|
||||
// Without this, url.Parse treats the host as the scheme ("localhost:9000" -> scheme "localhost").
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=localhost:9000")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "localhost:9000", cfg.Endpoint)
|
||||
require.True(t, cfg.PathStyle)
|
||||
require.Equal(t, "https://localhost:9000/my-bucket", cfg.BucketURL())
|
||||
}
|
||||
|
||||
func TestParseURL_EndpointNoSchemeHostname(t *testing.T) {
|
||||
// A dotted hostname with a port and no scheme must also default to https
|
||||
// ("minio.example.com:9000" must not become scheme "minio.example.com").
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=minio.example.com:9000")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "minio.example.com:9000", cfg.Endpoint)
|
||||
require.Equal(t, "https://minio.example.com:9000/my-bucket", cfg.BucketURL())
|
||||
}
|
||||
|
||||
func TestParseURL_EndpointTrailingSlash(t *testing.T) {
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=https://s3.example.com/")
|
||||
require.Nil(t, err)
|
||||
@@ -111,6 +135,11 @@ func TestConfig_BucketURL_PathStyle(t *testing.T) {
|
||||
require.Equal(t, "https://s3.example.com/my-bucket", c.BucketURL())
|
||||
}
|
||||
|
||||
func TestConfig_BucketURL_PathStyle_EndpointHTTP(t *testing.T) {
|
||||
c := &Config{Scheme: "http", Endpoint: "localhost:9000", Bucket: "b", PathStyle: true}
|
||||
require.Equal(t, "http://localhost:9000/b", c.BucketURL())
|
||||
}
|
||||
|
||||
func TestConfig_BucketURL_VirtualHosted(t *testing.T) {
|
||||
c := &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false}
|
||||
require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com", c.BucketURL())
|
||||
|
||||
+8
-3
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
// Config holds the parsed fields from an S3 URL. Use ParseURL to create one from a URL string.
|
||||
type Config struct {
|
||||
Scheme string // URL scheme, e.g. "https" or "http"
|
||||
Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com"
|
||||
PathStyle bool
|
||||
Bucket string
|
||||
@@ -24,10 +25,14 @@ type Config struct {
|
||||
|
||||
// BucketURL returns the base URL for bucket-level operations.
|
||||
func (c *Config) BucketURL() string {
|
||||
if c.PathStyle {
|
||||
return fmt.Sprintf("https://%s/%s", c.Endpoint, c.Bucket)
|
||||
scheme := "https"
|
||||
if c.Scheme != "" {
|
||||
scheme = c.Scheme
|
||||
}
|
||||
return fmt.Sprintf("https://%s.%s", c.Bucket, c.Endpoint)
|
||||
if c.PathStyle {
|
||||
return fmt.Sprintf("%s://%s/%s", scheme, c.Endpoint, c.Bucket)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s.%s", scheme, c.Bucket, c.Endpoint)
|
||||
}
|
||||
|
||||
// HostHeader returns the value for the Host header.
|
||||
|
||||
+10
-1
@@ -70,21 +70,30 @@ func ParseURL(s3URL string) (*Config, error) {
|
||||
return nil, fmt.Errorf("s3: region query parameter is required")
|
||||
}
|
||||
endpointParam := u.Query().Get("endpoint")
|
||||
var scheme string
|
||||
var endpoint string
|
||||
var pathStyle bool
|
||||
if endpointParam != "" {
|
||||
// Custom endpoint: strip scheme prefix to extract host[:port]
|
||||
// Custom endpoint: derive the scheme from the prefix and strip it to extract host[:port].
|
||||
// Default to https for backward compatibility, including bare "host:port" endpoints (no
|
||||
// scheme) -- url.Parse would otherwise misread the host before the port colon as the scheme.
|
||||
scheme = "https"
|
||||
if strings.HasPrefix(endpointParam, "http://") {
|
||||
scheme = "http"
|
||||
}
|
||||
ep := strings.TrimRight(endpointParam, "/")
|
||||
ep = strings.TrimPrefix(ep, "https://")
|
||||
ep = strings.TrimPrefix(ep, "http://")
|
||||
endpoint = ep
|
||||
pathStyle = true
|
||||
} else {
|
||||
scheme = "https"
|
||||
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", region)
|
||||
pathStyle = false
|
||||
}
|
||||
disableHTTP2, _ := strconv.ParseBool(u.Query().Get("disable_http2"))
|
||||
return &Config{
|
||||
Scheme: scheme,
|
||||
Endpoint: endpoint,
|
||||
PathStyle: pathStyle,
|
||||
Bucket: bucket,
|
||||
|
||||
@@ -11,11 +11,20 @@ if [ -z "$1" ]; then
|
||||
echo "Example:"
|
||||
echo " $0 emoji-converted.json"
|
||||
echo " $0 $ROOTDIR/web/src/app/emojis.js"
|
||||
echo " $0 $ROOTDIR/web/src/app/emojisMapped.js"
|
||||
echo " $0 $ROOTDIR/docs/emojis.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$1" == *.js ]]; then
|
||||
if [[ "$1" == *emojisMapped.js ]]; then
|
||||
# Small alias -> emoji lookup used to render tags as emojis. Precomputed so the full
|
||||
# emoji dataset (emojis.js) stays out of the main web bundle.
|
||||
echo -n "// This file is generated by scripts/emoji-convert.sh -- alias to emoji lookup
|
||||
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||
export default " > "$1"
|
||||
cat "$SCRIPTDIR/emoji.json" | jq -jc '[.[] | .aliases[] as $a | {key: $a, value: .emoji}] | from_entries' >> "$1"
|
||||
echo ";" >> "$1"
|
||||
elif [[ "$1" == *.js ]]; then
|
||||
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
|
||||
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||
export const rawEmojis = " > "$1"
|
||||
|
||||
+6
-8
@@ -90,6 +90,7 @@ var (
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||
updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
|
||||
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
|
||||
deletePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/delete$`)
|
||||
sequenceIDRegex = topicRegex
|
||||
|
||||
webAppConfigPath = "/config.js"
|
||||
@@ -654,9 +655,9 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) {
|
||||
} else if (r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path)) || (r.Method == http.MethodGet && deletePathRegex.MatchString(r.URL.Path)) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v)
|
||||
} else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) {
|
||||
} else if (r.Method == http.MethodGet || r.Method == http.MethodPut) && clearPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
@@ -1529,7 +1530,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Me
|
||||
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *model.Message) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {
|
||||
if err := util.EncodeJSON(&buf, msg.ForJSON()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
@@ -1540,7 +1541,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
|
||||
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *model.Message) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {
|
||||
if err := util.EncodeJSON(&buf, msg.ForJSON()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if msg.Event != model.MessageEvent && msg.Event != model.MessageDeleteEvent && msg.Event != model.MessageClearEvent {
|
||||
@@ -2404,10 +2405,7 @@ func (s *Server) writeJSON(w http.ResponseWriter, v any) error {
|
||||
func (s *Server) writeJSONWithContentType(w http.ResponseWriter, v any, contentType string) error {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return util.EncodeJSON(w, v)
|
||||
}
|
||||
|
||||
func (s *Server) updateAndWriteStats(messagesCount int64) {
|
||||
|
||||
@@ -180,10 +180,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
primaryEmail, err := s.userManager.PrimaryEmail(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pendingEmails, err := s.userManager.PendingEmails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -191,7 +187,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
// Combine verified (with primary flag) and pending (unverified) into one list
|
||||
emailInfos := make([]*apiAccountEmailInfo, 0, len(emails)+len(pendingEmails))
|
||||
for _, email := range emails {
|
||||
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Primary: email == primaryEmail})
|
||||
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email.Address, Primary: email.Primary})
|
||||
}
|
||||
for _, email := range pendingEmails {
|
||||
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true})
|
||||
@@ -667,7 +663,7 @@ func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if util.Contains(emails, req.Email) {
|
||||
} else if emails.Contains(req.Email) {
|
||||
return errHTTPConflictEmailExists
|
||||
}
|
||||
// Rate limit (counts against the user's email quota)
|
||||
@@ -888,7 +884,7 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if len(emails) > 0 {
|
||||
return emails[0], nil
|
||||
return emails[0].Address, nil
|
||||
}
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
}
|
||||
@@ -903,7 +899,7 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if util.Contains(emails, email) {
|
||||
} else if emails.Contains(email) {
|
||||
return email, nil
|
||||
}
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
|
||||
@@ -165,8 +165,5 @@ func writeMatrixResponse(w http.ResponseWriter, rejectedPushKey string) error {
|
||||
Rejected: rejected,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return util.EncodeJSON(w, response)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestPayments_Checkout_SendsBillingEmailVerification(t *testing.T) {
|
||||
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"billing@example.com"}, emails)
|
||||
require.Equal(t, []string{"billing@example.com"}, emails.Strings())
|
||||
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "billing@example.com", primary)
|
||||
|
||||
@@ -4139,6 +4139,40 @@ func TestServer_DeleteMessage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_DeleteMessage_GET(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// Publish a message with a sequence ID
|
||||
response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq123", msg.SequenceID)
|
||||
require.Equal(t, "message", msg.Event)
|
||||
|
||||
// Delete the message using GET method (/topic/seq/delete)
|
||||
response = request(t, s, "GET", "/mytopic/seq123/delete", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
deleteMsg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq123", deleteMsg.SequenceID)
|
||||
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||
|
||||
// Poll and verify both messages are returned
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
|
||||
require.Equal(t, 2, len(lines))
|
||||
|
||||
msg1 := toMessage(t, lines[0])
|
||||
msg2 := toMessage(t, lines[1])
|
||||
require.Equal(t, "message", msg1.Event)
|
||||
require.Equal(t, "message_delete", msg2.Event)
|
||||
require.Equal(t, "seq123", msg1.SequenceID)
|
||||
require.Equal(t, "seq123", msg2.SequenceID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_ClearMessage(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
@@ -4192,6 +4226,33 @@ func TestServer_ClearMessage_ReadEndpoint(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_ClearMessage_GET(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// 1. Test GET /topic/seq-id/clear
|
||||
response := request(t, s, "PUT", "/mytopic/seq456", "original message 1", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/seq456/clear", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
clearMsg1 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq456", clearMsg1.SequenceID)
|
||||
require.Equal(t, "message_clear", clearMsg1.Event)
|
||||
|
||||
// 2. Test GET /topic/seq-id/read
|
||||
response = request(t, s, "PUT", "/mytopic/seq789", "original message 2", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/seq789/read", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
clearMsg2 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq789", clearMsg2.SequenceID)
|
||||
require.Equal(t, "message_clear", clearMsg2.Event)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_UpdateMessage(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
@@ -4399,6 +4460,41 @@ func TestServer_DeleteScheduledMessage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_DeleteScheduledMessage_GET(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// Publish a scheduled message (future delivery)
|
||||
response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "delete-sched-seq", msg.SequenceID)
|
||||
|
||||
// Verify scheduled message exists
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "scheduled message to delete", messages[0].Message)
|
||||
|
||||
// Delete the scheduled message using GET method (/topic/seq/delete)
|
||||
response = request(t, s, "GET", "/mytopic/delete-sched-seq/delete", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
deleteMsg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID)
|
||||
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||
|
||||
// Verify scheduled message was deleted, only delete event remains
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "message_delete", messages[0].Event)
|
||||
require.Equal(t, "delete-sched-seq", messages[0].SequenceID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
|
||||
+34
-16
@@ -2,6 +2,7 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -676,6 +677,13 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error {
|
||||
if user != nil && user.Role == RoleAdmin {
|
||||
return nil // Admin can do everything
|
||||
}
|
||||
// A user always has full access to their own sync topic, which the apps use
|
||||
// to sync subscriptions/settings across devices. Without this, an
|
||||
// auth-default-access of "deny-all" locks the user out of their own sync
|
||||
// topic (no ACL entry is created for it at user creation). See #733.
|
||||
if user != nil && user.SyncTopic != "" && subtle.ConstantTimeCompare([]byte(topic), []byte(user.SyncTopic)) == 1 {
|
||||
return nil
|
||||
}
|
||||
username := Everyone
|
||||
if user != nil {
|
||||
username = user.Name
|
||||
@@ -933,7 +941,9 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
|
||||
|
||||
// Reservations returns all user-owned topics, and the associated everyone-access
|
||||
func (a *Manager) Reservations(username string) ([]Reservation, error) {
|
||||
return a.reservationsTx(a.db.ReadOnly(), username)
|
||||
// Read from the primary, not a replica: this backs GET /account, which the web app refetches
|
||||
// immediately after a sync event. Replication lag would otherwise show stale data.
|
||||
return a.reservationsTx(a.db, username)
|
||||
}
|
||||
|
||||
func (a *Manager) reservationsTx(tx db.Querier, username string) ([]Reservation, error) {
|
||||
@@ -1196,7 +1206,8 @@ func (a *Manager) Token(userID, token string) (*Token, error) {
|
||||
|
||||
// Tokens returns all existing tokens for the user with the given user ID
|
||||
func (a *Manager) Tokens(userID string) ([]*Token, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectTokens, userID)
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectTokens, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1409,7 +1420,8 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
||||
|
||||
// PhoneNumbers returns all phone numbers for the user with the given user ID
|
||||
func (a *Manager) PhoneNumbers(userID string) ([]string, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectPhoneNumbers, userID)
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectPhoneNumbers, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1457,14 +1469,17 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {
|
||||
return phoneNumber, nil
|
||||
}
|
||||
|
||||
// Emails returns all verified email addresses for the user with the given user ID
|
||||
func (a *Manager) Emails(userID string) ([]string, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectEmails, userID)
|
||||
// Emails returns all verified email addresses for the user with the given user ID, each carrying
|
||||
// whether it is the primary (recovery) address. Because the primary flag is included, callers that
|
||||
// need it (e.g. the account view) do not need a separate PrimaryEmail call.
|
||||
func (a *Manager) Emails(userID string) (Emails, error) {
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectEmails, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
emails := make([]string, 0)
|
||||
emails := make(Emails, 0)
|
||||
for {
|
||||
email, err := a.readEmail(rows)
|
||||
if errors.Is(err, ErrEmailNotFound) {
|
||||
@@ -1500,7 +1515,8 @@ func (a *Manager) RemoveEmail(userID, email string) error {
|
||||
// the user has not designated one.
|
||||
func (a *Manager) PrimaryEmail(userID string) (string, error) {
|
||||
var email sql.NullString
|
||||
err := a.db.ReadOnly().QueryRow(a.queries.selectPrimaryEmail, userID).Scan(&email)
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
err := a.db.QueryRow(a.queries.selectPrimaryEmail, userID).Scan(&email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
} else if err != nil {
|
||||
@@ -1526,7 +1542,8 @@ func (a *Manager) UserIDByPrimaryEmail(email string) (string, error) {
|
||||
// PendingEmails returns the user's unverified (pending) email addresses, i.e. addresses with
|
||||
// an outstanding email-verification magic link.
|
||||
func (a *Manager) PendingEmails(userID string) ([]string, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectPendingEmails, string(MagicLinkKindEmailVerify), userID)
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectPendingEmails, string(MagicLinkKindEmailVerify), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1748,17 +1765,18 @@ func (a *Manager) deleteExpiredMagicLinks() error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Manager) readEmail(rows *sql.Rows) (string, error) {
|
||||
var email string
|
||||
func (a *Manager) readEmail(rows *sql.Rows) (*Email, error) {
|
||||
var address string
|
||||
var primary bool
|
||||
if !rows.Next() {
|
||||
return "", ErrEmailNotFound
|
||||
return nil, ErrEmailNotFound
|
||||
}
|
||||
if err := rows.Scan(&email); err != nil {
|
||||
return "", err
|
||||
if err := rows.Scan(&address, &primary); err != nil {
|
||||
return nil, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
return &Email{Address: address, Primary: primary}, nil
|
||||
}
|
||||
|
||||
// ChangeBilling updates a user's billing fields
|
||||
|
||||
@@ -217,7 +217,7 @@ const (
|
||||
postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
|
||||
|
||||
// Email queries
|
||||
postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email`
|
||||
postgresSelectEmailsQuery = `SELECT email, is_primary FROM user_email WHERE user_id = $1 ORDER BY email`
|
||||
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
|
||||
postgresInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2) ON CONFLICT (user_id, email) DO NOTHING`
|
||||
postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2`
|
||||
|
||||
@@ -214,7 +214,7 @@ const (
|
||||
sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
|
||||
|
||||
// Email queries
|
||||
sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email`
|
||||
sqliteSelectEmailsQuery = `SELECT email, is_primary FROM user_email WHERE user_id = ? ORDER BY email`
|
||||
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
|
||||
sqliteInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?) ON CONFLICT (user_id, email) DO NOTHING`
|
||||
sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
|
||||
|
||||
+107
-6
@@ -130,6 +130,13 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
||||
require.Nil(t, a.Authorize(ben, "announcements", PermissionRead))
|
||||
require.Equal(t, ErrUnauthorized, a.Authorize(ben, "announcements", PermissionWrite))
|
||||
|
||||
// User has full access to their own sync topic, even under deny-all,
|
||||
// but not to another user's sync topic (#733)
|
||||
require.Nil(t, a.Authorize(ben, ben.SyncTopic, PermissionRead))
|
||||
require.Nil(t, a.Authorize(ben, ben.SyncTopic, PermissionWrite))
|
||||
require.Equal(t, ErrUnauthorized, a.Authorize(ben, john.SyncTopic, PermissionRead))
|
||||
require.Equal(t, ErrUnauthorized, a.Authorize(ben, john.SyncTopic, PermissionWrite))
|
||||
|
||||
// User john should have
|
||||
// "deny" to mytopic_deny*,
|
||||
// "ro" to mytopic_ro*,
|
||||
@@ -1150,7 +1157,7 @@ func TestUser_EmailAddListRemove(t *testing.T) {
|
||||
emails, err := a.Emails(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(emails))
|
||||
require.Equal(t, "phil@example.com", emails[0])
|
||||
require.Equal(t, "phil@example.com", emails[0].Address)
|
||||
|
||||
require.Nil(t, a.RemoveEmail(phil.ID, "phil@example.com"))
|
||||
emails, err = a.Emails(phil.ID)
|
||||
@@ -2694,7 +2701,7 @@ func TestStoreEmails(t *testing.T) {
|
||||
emails, err = manager.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, emails, 1)
|
||||
require.Equal(t, "phil2@example.com", emails[0])
|
||||
require.Equal(t, "phil2@example.com", emails[0].Address)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2864,7 +2871,7 @@ func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) {
|
||||
|
||||
emails, err = a.Emails(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"phil@example.com"}, emails)
|
||||
require.Equal(t, []string{"phil@example.com"}, emails.Strings())
|
||||
primary, err = a.PrimaryEmail(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil@example.com", primary)
|
||||
@@ -2897,7 +2904,7 @@ func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) {
|
||||
// Both verified, but primary is still the first
|
||||
emails, err := a.Emails(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"first@example.com", "second@example.com"}, emails)
|
||||
require.Equal(t, []string{"first@example.com", "second@example.com"}, emails.Strings())
|
||||
primary, err := a.PrimaryEmail(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "first@example.com", primary)
|
||||
@@ -2926,7 +2933,7 @@ func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
emails, err := a.Emails(ben.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"shared@example.com"}, emails)
|
||||
require.Equal(t, []string{"shared@example.com"}, emails.Strings())
|
||||
primary, err = a.PrimaryEmail(ben.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "", primary)
|
||||
@@ -3161,7 +3168,7 @@ func TestUser_MagicLink_VerifyEmail_ProvisionedGetsPrimary(t *testing.T) {
|
||||
|
||||
emails, err := a.Emails(prov.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"prov@example.com"}, emails)
|
||||
require.Equal(t, []string{"prov@example.com"}, emails.Strings())
|
||||
primary, err := a.PrimaryEmail(prov.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "prov@example.com", primary)
|
||||
@@ -3212,3 +3219,97 @@ func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) {
|
||||
require.ErrorIs(t, err, ErrUserNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_Emails_PrimaryFlagAndHelpers(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
u, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, a.AddEmail(u.ID, "a@example.com"))
|
||||
require.Nil(t, a.AddEmail(u.ID, "b@example.com"))
|
||||
require.Nil(t, a.SetPrimaryEmail(u.ID, "b@example.com"))
|
||||
|
||||
// Emails() carries the primary flag, so a separate PrimaryEmail() call is unnecessary.
|
||||
emails, err := a.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, emails, 2)
|
||||
require.Equal(t, "a@example.com", emails[0].Address) // ORDER BY email
|
||||
require.False(t, emails[0].Primary)
|
||||
require.Equal(t, "b@example.com", emails[1].Address)
|
||||
require.True(t, emails[1].Primary)
|
||||
|
||||
// Helper methods for the address-only callers
|
||||
require.Equal(t, []string{"a@example.com", "b@example.com"}, emails.Strings())
|
||||
require.True(t, emails.Contains("a@example.com"))
|
||||
require.False(t, emails.Contains("c@example.com"))
|
||||
})
|
||||
}
|
||||
|
||||
// openReplicaTestSQLite opens a fresh SQLite database file with the user schema applied.
|
||||
func openReplicaTestSQLite(t *testing.T, filename string) *sql.DB {
|
||||
d, err := sql.Open("sqlite3", filename+"?_case_sensitive_like=on")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, setupSQLite(d))
|
||||
return d
|
||||
}
|
||||
|
||||
// TestManager_AccountReadsUsePrimary verifies that the per-user reads backing the GET /account
|
||||
// endpoint read from the primary, not from a read replica. The sync event ("something changed")
|
||||
// is published immediately after a write, so a replica that lags would make the account view
|
||||
// stale right after the user changes it. These reads must therefore be read-your-writes consistent.
|
||||
//
|
||||
// The test wires up a primary and a deliberately-empty replica (simulating replication lag),
|
||||
// forces the replica healthy so ReadOnly() would route to it, writes everything to the primary,
|
||||
// and asserts the reads still observe the fresh primary data.
|
||||
func TestManager_AccountReadsUsePrimary(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
primaryDB := openReplicaTestSQLite(t, filepath.Join(dir, "primary.db"))
|
||||
replicaDB := openReplicaTestSQLite(t, filepath.Join(dir, "replica.db")) // intentionally left empty
|
||||
|
||||
pool := db.New(&db.Host{DB: primaryDB}, []*db.Host{{DB: replicaDB}})
|
||||
pool.MarkReplicasHealthyForTest() // force ReadOnly() to route to the stale replica
|
||||
a, err := newManager(pool, sqliteQueries, &Config{BcryptCost: bcrypt.MinCost})
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { a.Close() })
|
||||
|
||||
// All writes below go to the primary; the replica stays empty.
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
u, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
_, err = a.CreateToken(u.ID, "test token", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, a.AddReservation("phil", "mytopic", PermissionDenyAll, 10))
|
||||
require.Nil(t, a.AddPhoneNumber(u.ID, "+12223334444"))
|
||||
require.Nil(t, a.AddEmail(u.ID, "phil@example.com"))
|
||||
require.Nil(t, a.SetPrimaryEmail(u.ID, "phil@example.com"))
|
||||
_, err = a.AddMagicLink(MagicLinkKindEmailVerify, u.ID, "pending@example.com", time.Hour)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Each read must observe the just-written primary data, NOT the empty replica.
|
||||
tokens, err := a.Tokens(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, tokens, 1)
|
||||
|
||||
reservations, err := a.Reservations("phil")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, reservations, 1)
|
||||
|
||||
phoneNumbers, err := a.PhoneNumbers(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, phoneNumbers, 1)
|
||||
|
||||
emails, err := a.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, emails, 1)
|
||||
require.Equal(t, "phil@example.com", emails[0].Address)
|
||||
require.True(t, emails[0].Primary)
|
||||
|
||||
primaryEmail, err := a.PrimaryEmail(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil@example.com", primaryEmail)
|
||||
|
||||
pendingEmails, err := a.PendingEmails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, pendingEmails, 1)
|
||||
}
|
||||
|
||||
@@ -182,6 +182,36 @@ type Reservation struct {
|
||||
Everyone Permission
|
||||
}
|
||||
|
||||
// Email is a verified email address on a user account, along with whether it is the user's
|
||||
// designated primary (recovery) address.
|
||||
type Email struct {
|
||||
Address string
|
||||
Primary bool
|
||||
}
|
||||
|
||||
// Emails is a list of verified email addresses for a user.
|
||||
type Emails []*Email
|
||||
|
||||
// Strings returns just the address strings, in the same order. It is a convenience for callers
|
||||
// that only care about the addresses and not the primary flag.
|
||||
func (e Emails) Strings() []string {
|
||||
addresses := make([]string, len(e))
|
||||
for i, email := range e {
|
||||
addresses[i] = email.Address
|
||||
}
|
||||
return addresses
|
||||
}
|
||||
|
||||
// Contains reports whether the given address is in the list.
|
||||
func (e Emails) Contains(address string) bool {
|
||||
for _, email := range e {
|
||||
if email.Address == address {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Permission represents a read or write permission to a topic
|
||||
type Permission uint8
|
||||
|
||||
|
||||
@@ -359,6 +359,16 @@ func MaybeMarshalJSON(v any) string {
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// EncodeJSON writes the JSON encoding of v to w, without escaping HTML-significant
|
||||
// characters (<, >, &). Unlike the standard library's default, ntfy does not embed its
|
||||
// JSON responses in HTML, so escaping these characters only makes the raw output harder
|
||||
// to read (see #1511).
|
||||
func EncodeJSON(w io.Writer, v any) error {
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetEscapeHTML(false)
|
||||
return encoder.Encode(v)
|
||||
}
|
||||
|
||||
// QuoteCommand combines a command array to a string, quoting arguments that need quoting.
|
||||
// This function is naive, and sometimes wrong. It is only meant for lo pretty-printing a command.
|
||||
//
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/netip"
|
||||
@@ -299,3 +300,10 @@ func TestMaybeMarshalJSON(t *testing.T) {
|
||||
require.Equal(t, `"`+strings.Repeat("x", 4999), MaybeMarshalJSON(strings.Repeat("x", 6000)))
|
||||
|
||||
}
|
||||
|
||||
func TestEncodeJSON(t *testing.T) {
|
||||
// HTML-significant characters (<, >, &) must NOT be escaped, see #1511
|
||||
var buf bytes.Buffer
|
||||
require.Nil(t, EncodeJSON(&buf, map[string]string{"message": "<b>a&b</b>"}))
|
||||
require.Equal(t, `{"message":"<b>a&b</b>"}`+"\n", buf.String())
|
||||
}
|
||||
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
src/app/emojis.js
|
||||
src/app/emojis.js
|
||||
src/app/emojisMapped.js
|
||||
|
||||
@@ -2,3 +2,4 @@ build/
|
||||
dist/
|
||||
public/static/langs/
|
||||
src/app/emojis.js
|
||||
src/app/emojisMapped.js
|
||||
|
||||
+85
-1
@@ -14,7 +14,7 @@
|
||||
<meta name="msapplication-navbutton-color" content="#317f6f" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
|
||||
<link rel="apple-touch-icon" href="/static/images/apple-touch-icon.png" sizes="180x180" />
|
||||
<link rel="mask-icon" href="/static/images/mask-icon.svg" color="#317f6f" />
|
||||
<link rel="mask-icon" href="/static/images/ntfy-mask.svg" color="#317f6f" />
|
||||
|
||||
<!-- Favicon, see favicon.io -->
|
||||
<link rel="icon" type="image/png" href="/static/images/favicon.ico" />
|
||||
@@ -44,6 +44,85 @@
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<!-- Splash: painted before the JS bundle loads, faded out by the app once ready (see
|
||||
src/app/splash.js). Background matches MUI's grey[100]/grey[900] for a seamless handoff. -->
|
||||
<style>
|
||||
html {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: #212121;
|
||||
}
|
||||
|
||||
#splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
opacity: 1;
|
||||
/* Background fade = the app fading in once the logo is gone (see src/app/splash.js). */
|
||||
transition: opacity 0.1s ease-out;
|
||||
}
|
||||
|
||||
html.dark #splash {
|
||||
background-color: #212121;
|
||||
}
|
||||
|
||||
#splash.splash-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#splash img {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
/* Gently pulse while loading; src/app/splash.js stops this and fades the logo out. */
|
||||
animation: splash-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes splash-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#splash {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#splash img {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Decide light/dark before first paint from the "prefcache" blob (written by PrefCache.jsx) --
|
||||
avoids the async-IndexedDB theme flash. Keep the key in sync with PrefCache.jsx. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var cache = JSON.parse(localStorage.getItem("prefcache"));
|
||||
var theme = cache && cache.theme;
|
||||
var prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
var dark = theme === "dark" || ((!theme || theme === "system") && prefersDark);
|
||||
if (dark) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
} catch (e) {
|
||||
/* localStorage/matchMedia/JSON unavailable -- fall back to the default light splash */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
@@ -51,6 +130,11 @@
|
||||
<a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a> or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to
|
||||
subscribe.
|
||||
</noscript>
|
||||
|
||||
<!-- Static splash, removed by src/app/splash.js once ready. Logo is a same-origin SVG (precached by the SW). -->
|
||||
<div id="splash" aria-hidden="true">
|
||||
<img src="/static/images/ntfy-splash.svg" alt="" />
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
|
||||
Generated
+1524
-996
File diff suppressed because it is too large
Load Diff
+13
-10
@@ -8,30 +8,32 @@
|
||||
"serve": "vite preview",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/"
|
||||
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.4.2",
|
||||
"@mui/material": "latest",
|
||||
"dexie": "^3.2.1",
|
||||
"dexie-react-hooks": "^1.1.1",
|
||||
"@mui/icons-material": "^9.1.1",
|
||||
"@mui/material": "^9.1.2",
|
||||
"dexie": "^4.4.4",
|
||||
"dexie-react-hooks": "^4.4.0",
|
||||
"i18next": "^21.6.14",
|
||||
"i18next-browser-languagedetector": "^6.1.4",
|
||||
"i18next-http-backend": "^3.0.5",
|
||||
"i18next-http-backend": "^4.0.0",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"react-i18next": "^11.16.2",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-infinite-scroll-component": "^7.2.1",
|
||||
"react-remark": "^2.1.0",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"stylis": "^4.3.0",
|
||||
"stylis-plugin-rtl": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
@@ -40,8 +42,9 @@
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"prettier": "^2.8.8",
|
||||
"vite": "^6.4.2",
|
||||
"vite-plugin-pwa": "^1.0.0"
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="#9a9a9a">
|
||||
<g transform="translate(-51.451771,-87.327048)">
|
||||
<path d="m 59.291677,93.677052 c -3.579993,0 -6.646873,2.817003 -6.646873,6.398338 v 0.003 l 0.03508,27.86677 -0.899113,6.63475 12.226096,-3.24797 H 94.40052 c 3.579985,0 6.64687,-2.82079 6.64687,-6.40216 v -24.85449 c 0,-3.580312 -3.065184,-6.39668 -6.643822,-6.398338 h -0.0031 z m 0,4.516205 h 35.108844 0.0031 c 1.257851,0.0013 2.12767,0.916373 2.12767,1.882133 v 24.85442 c 0,0.9666 -0.871353,1.88213 -2.13072,1.88213 H 63.344139 l -6.211425,1.87679 0.0633,-0.36604 -0.03431,-28.2473 c 0,-0.966516 0.870609,-1.882133 2.129956,-1.882133 z" />
|
||||
<g transform="matrix(2.1452134,0,0,2.5503116,-71.247407,-178.388)">
|
||||
<path d="m 62.57046,116.77004 v -1.31201 l 3.280018,-1.45904 q 0.158346,-0.0679 0.305381,-0.1018 0.158346,-0.0452 0.282761,-0.0679 0.135725,-0.0113 0.271449,-0.0226 v -0.0905 q -0.135724,-0.0113 -0.271449,-0.0452 -0.124415,-0.0226 -0.282761,-0.0566 -0.147035,-0.0452 -0.305381,-0.1131 l -3.280018,-1.45904 v -1.32332 l 5.067063,2.31863 v 1.4138 z" />
|
||||
<path d="m 62.308594,110.31055 v 1.90234 l 3.4375,1.5293 c 0.0073,0.003 0.0142,0.005 0.02148,0.008 -0.0073,0.003 -0.0142,0.005 -0.02148,0.008 l -3.4375,1.5293 v 1.89258 l 0.371093,-0.16992 5.220704,-2.39063 v -1.75 z m 0.52539,0.8164 4.541016,2.08008 v 1.07617 l -4.541016,2.07813 v -0.73242 l 3.119141,-1.38868 0.0039,-0.002 c 0.09141,-0.0389 0.178343,-0.0676 0.257813,-0.0859 h 0.0059 l 0.0078,-0.002 c 0.09483,-0.0271 0.176055,-0.0474 0.246093,-0.0606 l 0.498047,-0.041 v -0.57422 l -0.240234,-0.0195 c -0.07606,-0.006 -0.153294,-0.0198 -0.230469,-0.0391 l -0.0078,-0.002 -0.0078,-0.002 c -0.07608,-0.0138 -0.16556,-0.0318 -0.263672,-0.0527 -0.08398,-0.0262 -0.172736,-0.058 -0.265625,-0.0977 l -0.0039,-0.002 -3.119141,-1.38868 z" />
|
||||
</g>
|
||||
<g transform="matrix(2.1388566,0,0,2.4558588,-69.745456,-170.93962)">
|
||||
<path d="m 69.17132,117.75404 h 5.428996 v 1.27808 H 69.17132 Z" />
|
||||
<path d="m 68.908203,117.49219 v 0.26172 1.54101 h 5.955078 v -1.80273 z m 0.525391,0.52344 h 4.904297 v 0.7539 h -4.904297 z" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -405,5 +405,7 @@
|
||||
"web_push_subscription_expiring_body": "За да продължите да получавате известия, отворете ntfy",
|
||||
"action_bar_unmute_notifications": "Включване звука на известията",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Кодът за защита от външна система не може да бъде променян или премахван",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Потребител от външна система не може да бъде променян или премахван"
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Потребител от външна система не може да бъде променян или премахван",
|
||||
"common_close": "Затваряне",
|
||||
"common_refresh": "Презареждане"
|
||||
}
|
||||
|
||||
@@ -405,5 +405,59 @@
|
||||
"web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest",
|
||||
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Bereitgestelltes Token kann nicht bearbeitet oder gelöscht werden",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Ein bereitgestellter Benutzer kann nicht bearbeitet oder gelöscht werden"
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Ein bereitgestellter Benutzer kann nicht bearbeitet oder gelöscht werden",
|
||||
"common_close": "Schließen",
|
||||
"common_refresh": "Aktualisieren",
|
||||
"email_verify_progress_title": "Deine E-Mail-Adresse wird verifiziert...",
|
||||
"email_verify_success_title": "E-Mail-Adresse verifiziert",
|
||||
"email_verify_success_description": "Deine E-Mail-Adresse wurde verifiziert und Deinem Konto hinzugefügt.",
|
||||
"email_verify_error_title": "Verifizierung fehlgeschlagen",
|
||||
"email_verify_error_description": "Dieser Verifizierungslink ist ungültig oder abgelaufen. Du kannst in Deinen Konto-Einstellungen einen neuen anfordern.",
|
||||
"email_verify_button_account": "Zum Konto",
|
||||
"version_update_available_title": "Neue Version verfügbar",
|
||||
"version_update_available_description": "Der ntfy-Server wurde aktualisiert. Bitte lade die Seite neu.",
|
||||
"signup_form_email": "E-Mail (optional, zur Konto-Wiederherstellung)",
|
||||
"login_link_forgot_password": "Kennwort vergessen",
|
||||
"reset_password_request_title": "Kennwort zurücksetzen",
|
||||
"reset_password_request_description": "Gib Deinen Benutzernamen oder Deine E-Mail-Adresse ein. Falls ein Konto existiert, wird ein Link zum Zurücksetzen des Kennworts per E-Mail gesendet.",
|
||||
"reset_password_request_primary_required": "Dies funktioniert nur, wenn Du bereits eine primäre E-Mail-Adresse hinzugefügt und verifiziert hast.",
|
||||
"reset_password_request_identifier_label": "Benutzername oder E-Mail",
|
||||
"reset_password_request_button_submit": "Link zum Zurücksetzen senden",
|
||||
"reset_password_sent_title": "Überprüfe Deinen Posteingang",
|
||||
"reset_password_sent_description": "Falls ein Konto existiert, wurde ein Link zum Zurücksetzen des Kennworts per E-Mail gesendet.",
|
||||
"reset_password_back_to_login": "Zurück zur Anmeldung",
|
||||
"reset_password_disabled": "Das Zurücksetzen des Kennworts ist deaktiviert",
|
||||
"reset_password_title": "Neues Kennwort festlegen",
|
||||
"reset_password_form_password": "Neues Kennwort",
|
||||
"reset_password_form_confirm": "Neues Kennwort bestätigen",
|
||||
"reset_password_form_button_submit": "Kennwort festlegen",
|
||||
"reset_password_form_error_invalid": "Dieser Link zum Zurücksetzen ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
|
||||
"reset_password_success_title": "Kennwort geändert",
|
||||
"reset_password_success_description": "Dein Kennwort wurde geändert. Du kannst Dich nun mit Deinem neuen Kennwort anmelden.",
|
||||
"action_bar_reload": "App neu laden",
|
||||
"account_basics_emails_title": "E-Mail-Adressen",
|
||||
"account_basics_emails_description": "Für E-Mail-Benachrichtigungen und das Zurücksetzen des Kennworts",
|
||||
"account_basics_emails_no_emails_yet": "Noch keine E-Mail-Adressen",
|
||||
"account_basics_emails_copied_to_clipboard": "E-Mail-Adresse in die Zwischenablage kopiert",
|
||||
"account_basics_emails_chip_actions_primary": "Primäre Adresse, wird als Deine standardmäßige E-Mail-Adresse verwendet. Klicke für Aktionen.",
|
||||
"account_basics_emails_chip_actions_verified": "Kann für Benachrichtigungen verwendet werden. Klicke für Aktionen.",
|
||||
"account_basics_emails_chip_actions_unverified": "Nicht verifizierte Adresse, überprüfe Deinen Posteingang, um sie zu verifizieren. Klicke für Aktionen.",
|
||||
"account_basics_emails_unverified": "nicht verifiziert",
|
||||
"account_basics_emails_set_primary": "Als primäre E-Mail-Adresse festlegen",
|
||||
"account_basics_emails_delete": "Adresse entfernen",
|
||||
"account_basics_emails_resend": "Verifizierungs-E-Mail erneut senden",
|
||||
"account_basics_emails_resent": "Verifizierungs-E-Mail gesendet, überprüfe Deinen Posteingang",
|
||||
"account_basics_emails_primary_elsewhere": "Diese E-Mail-Adresse wird als primäre Adresse eines anderen Kontos verwendet",
|
||||
"account_basics_emails_no_recovery_warning": "Füge mindestens eine E-Mail-Adresse hinzu, damit Du Dein Konto wiederherstellen kannst, falls Du Dein Kennwort verlierst.",
|
||||
"account_basics_emails_no_primary_warning": "Füge eine primäre E-Mail-Adresse hinzu, damit Du Dein Konto wiederherstellen kannst, falls Du Dein Kennwort verlierst.",
|
||||
"account_basics_emails_dialog_title": "E-Mail-Adresse hinzufügen",
|
||||
"account_basics_emails_dialog_description": "Gib eine E-Mail-Adresse ein, um sie Deinem Konto hinzuzufügen. Es wird ein Verifizierungslink gesendet, um zu bestätigen, dass sie Dir gehört.",
|
||||
"account_basics_emails_dialog_email_label": "E-Mail-Adresse",
|
||||
"account_basics_emails_dialog_email_placeholder": "z.B. user@example.com",
|
||||
"account_basics_emails_dialog_verify_button": "Verifizierungslink senden",
|
||||
"account_basics_emails_dialog_check_inbox": "Überprüfe Deinen Posteingang und klicke auf den Verifizierungslink, um diese E-Mail-Adresse zu bestätigen. Sie wird als nicht verifiziert angezeigt, bis Du dies tust.",
|
||||
"account_basics_tier_provisioned": "Bereitgestellt",
|
||||
"account_usage_emails_none": "Mit diesem Konto können keine E-Mail-Benachrichtigungen gesendet werden",
|
||||
"prefs_users_dialog_base_url_invalid": "Ungültiges URL-Format. Muss mit http:// oder https:// beginnen",
|
||||
"prefs_users_dialog_base_url_exists": "Für diese Service-URL existiert bereits ein Benutzer"
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"action_bar_profile_logout": "Logout",
|
||||
"action_bar_sign_in": "Sign in",
|
||||
"action_bar_sign_up": "Sign up",
|
||||
"action_bar_reload": "Reload app",
|
||||
"message_bar_type_message": "Type a message here",
|
||||
"message_bar_error_publishing": "Error publishing notification",
|
||||
"message_bar_show_dialog": "Show publish dialog",
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
"action_bar_reservation_add": "Reservar tópico",
|
||||
"action_bar_sign_up": "Registar",
|
||||
"nav_button_account": "Conta",
|
||||
"common_copy_to_clipboard": "Copiar à área de transferência",
|
||||
"common_copy_to_clipboard": "Copiar para a área de transferência",
|
||||
"nav_upgrade_banner_label": "Upgrade para ntfy Pro",
|
||||
"alert_not_supported_context_description": "As notificações são apenas suportadas através de HTTPS. Isto é uma limitação da <mdnLink>Notifications API</mdnLink>.",
|
||||
"display_name_dialog_title": "Alterar o nome público",
|
||||
@@ -406,5 +406,35 @@
|
||||
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
|
||||
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Não se pode editar ou eliminar um usuário predefinido",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não se pode editar ou eliminar um token predefinido"
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não se pode editar ou eliminar um token predefinido",
|
||||
"common_close": "Fechar",
|
||||
"common_refresh": "Atualizar",
|
||||
"email_verify_progress_title": "A verificar o email...",
|
||||
"email_verify_success_title": "Email verificado",
|
||||
"email_verify_success_description": "O seu email foi verificado e adicionado à sua conta.",
|
||||
"email_verify_error_title": "Verificação falhada",
|
||||
"email_verify_error_description": "Este link de verificação é inválido ou expirou. Pode pedir um novo nas definições da sua conta.",
|
||||
"email_verify_button_account": "Ir para conta",
|
||||
"version_update_available_title": "Nova sessão disponível",
|
||||
"version_update_available_description": "O servidor ntfy foi atualizado. Por favor atualiza a página.",
|
||||
"signup_form_email": "Email (opcional, para recuperação de conta)",
|
||||
"login_link_forgot_password": "Esqueci a palavra-passe",
|
||||
"reset_password_request_title": "Redefinir palavra-passe",
|
||||
"reset_password_request_description": "Introduza o seu nome de utilizador ou email. Se a conta existir, um link de redefinição de palavra-passe será enviado para o email.",
|
||||
"reset_password_request_primary_required": "Isto apenas funciona se adicionar e verificar um email principal.",
|
||||
"reset_password_request_identifier_label": "Nome de utilizador ou email",
|
||||
"reset_password_request_button_submit": "Enviar link de redefinição",
|
||||
"reset_password_sent_title": "Verifique a caixa de entrada",
|
||||
"reset_password_sent_description": "Se a conta existir, um link de redefinição de palavra-passe foi enviado.",
|
||||
"reset_password_back_to_login": "Voltar a início de sessão",
|
||||
"reset_password_disabled": "Redefinição de palavra-passe desativada",
|
||||
"reset_password_title": "Definir uma nova palavra-passe",
|
||||
"reset_password_form_password": "Nova palavra-passe",
|
||||
"reset_password_form_confirm": "Confirme a nova palavra-passe",
|
||||
"reset_password_form_button_submit": "Definir palavra-passe",
|
||||
"reset_password_form_error_invalid": "O link de redefinição é invalido ou expirou. Por favor peça um novo.",
|
||||
"reset_password_success_title": "Palavra-passe alterada",
|
||||
"reset_password_success_description": "A sua palavra passe foi alterada. Pode agora iniciar sessão com a nova palavra passe.",
|
||||
"action_bar_reload": "Reiniciar aplicação",
|
||||
"account_basics_emails_title": "Endereços de email"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -247,8 +247,8 @@
|
||||
"account_basics_tier_title": "账户类型",
|
||||
"account_basics_tier_description": "您账户的权限级别",
|
||||
"account_basics_tier_admin": "管理员",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等级)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(无等级)",
|
||||
"account_basics_tier_admin_suffix_with_tier": "等级为 {{tier}}",
|
||||
"account_basics_tier_admin_suffix_no_tier": "无等级",
|
||||
"account_basics_tier_basic": "基础版",
|
||||
"account_basics_tier_free": "免费",
|
||||
"account_basics_tier_upgrade_button": "升级到专业版",
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import session from "./Session";
|
||||
import prefs from "./Prefs";
|
||||
import subscriptionManager from "./SubscriptionManager";
|
||||
import accountApi from "./AccountApi";
|
||||
|
||||
// AccountApi.js leans on several singletons; mock them so we can drive token() and assert the
|
||||
// side effects of sync(), and so importing it doesn't drag in the component/Dexie/i18n trees.
|
||||
// (vi.mock is hoisted above the imports by vitest, so those imports receive the mocks.)
|
||||
vi.mock("./Session", () => ({
|
||||
default: { token: vi.fn(() => "test-token"), setLastExtendedAtAsync: vi.fn(), resetAndRedirect: vi.fn() },
|
||||
}));
|
||||
vi.mock("./SubscriptionManager", () => ({ default: { syncFromRemote: vi.fn() } }));
|
||||
vi.mock("./Prefs", () => ({
|
||||
default: { setSound: vi.fn(), setDeleteAfter: vi.fn(), setMinPriority: vi.fn() },
|
||||
THEME: { DARK: "dark", LIGHT: "light", SYSTEM: "system" },
|
||||
}));
|
||||
vi.mock("i18next", () => ({ default: { changeLanguage: vi.fn() } }));
|
||||
vi.mock("../components/routes", () => ({ default: { login: "/login" } }));
|
||||
|
||||
let fetchMock;
|
||||
|
||||
// fetchOrThrow treats anything other than HTTP 200 as an error, so a fake response just needs a
|
||||
// status and a json() method.
|
||||
const ok = (body = {}) => ({ status: 200, json: async () => body });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
session.token.mockReturnValue("test-token");
|
||||
accountApi.tiers = null; // reset the billing-tiers cache between tests
|
||||
accountApi.listener = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("AccountApi.login", () => {
|
||||
it("POSTs basic auth to the token URL and returns the token", async () => {
|
||||
fetchMock.mockResolvedValue(ok({ token: "tk_returned" }));
|
||||
const token = await accountApi.login({ username: "phil", password: "secret" });
|
||||
|
||||
expect(token).toBe("tk_returned");
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://ntfy.sh/v1/account/token");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`);
|
||||
});
|
||||
|
||||
it("throws when the server response has no token", async () => {
|
||||
fetchMock.mockResolvedValue(ok({}));
|
||||
await expect(accountApi.login({ username: "phil", password: "secret" })).rejects.toThrow("Cannot find token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.create", () => {
|
||||
it("POSTs username/password and defaults email to an empty string", async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
await accountApi.create("phil", "pw");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/v1/account",
|
||||
expect.objectContaining({ method: "POST", body: JSON.stringify({ username: "phil", password: "pw", email: "" }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.get", () => {
|
||||
it("returns the parsed account and notifies the listener", async () => {
|
||||
fetchMock.mockResolvedValue(ok({ username: "phil" }));
|
||||
const listener = vi.fn();
|
||||
accountApi.registerListener(listener);
|
||||
|
||||
const account = await accountApi.get();
|
||||
|
||||
expect(account).toEqual({ username: "phil" });
|
||||
expect(listener).toHaveBeenCalledWith({ username: "phil" });
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://ntfy.sh/v1/account");
|
||||
expect(options.headers.Authorization).toBe("Bearer test-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.changePassword", () => {
|
||||
it("POSTs the current and new passwords", async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
await accountApi.changePassword("old", "new");
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.body).toBe(JSON.stringify({ password: "old", new_password: "new" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.createToken", () => {
|
||||
it("converts a positive expiry into an absolute unix timestamp", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); // 1767225600 seconds
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
|
||||
await accountApi.createToken("my label", 3600);
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body)).toEqual({ label: "my label", expires: 1767225600 + 3600 });
|
||||
});
|
||||
|
||||
it("sends expires=0 for a non-expiring token", async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
await accountApi.createToken("forever", 0);
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body).expires).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.deleteToken", () => {
|
||||
it("passes the target token in the X-Token header alongside the bearer token", async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
await accountApi.deleteToken("tk_target");
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.method).toBe("DELETE");
|
||||
expect(options.headers["X-Token"]).toBe("tk_target");
|
||||
expect(options.headers.Authorization).toBe("Bearer test-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi subscriptions", () => {
|
||||
it("addSubscription POSTs base_url/topic and returns the parsed subscription", async () => {
|
||||
fetchMock.mockResolvedValue(ok({ id: "sub_1" }));
|
||||
const subscription = await accountApi.addSubscription("https://ntfy.sh", "mytopic");
|
||||
|
||||
expect(subscription).toEqual({ id: "sub_1" });
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://ntfy.sh/v1/account/subscription");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(JSON.parse(options.body)).toEqual({ base_url: "https://ntfy.sh", topic: "mytopic" });
|
||||
});
|
||||
|
||||
it("deleteSubscription passes baseUrl/topic via headers", async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
await accountApi.deleteSubscription("https://ntfy.sh", "mytopic");
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.method).toBe("DELETE");
|
||||
expect(options.headers["X-BaseURL"]).toBe("https://ntfy.sh");
|
||||
expect(options.headers["X-Topic"]).toBe("mytopic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.billingTiers", () => {
|
||||
it("caches the tiers and only fetches once", async () => {
|
||||
fetchMock.mockResolvedValue(ok([{ code: "pro" }]));
|
||||
const first = await accountApi.billingTiers();
|
||||
const second = await accountApi.billingTiers();
|
||||
|
||||
expect(first).toEqual([{ code: "pro" }]);
|
||||
expect(second).toBe(first);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.requestPasswordReset", () => {
|
||||
it("POSTs the identifier without any auth header", async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
await accountApi.requestPasswordReset("phil");
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://ntfy.sh/v1/account/password/reset/request");
|
||||
expect(options.body).toBe(JSON.stringify({ identifier: "phil" }));
|
||||
expect(options.headers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.sync", () => {
|
||||
it("returns null and makes no request when there is no token", async () => {
|
||||
session.token.mockReturnValue(null);
|
||||
expect(await accountApi.sync()).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets the session and redirects to login on an unauthorized response", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 401, json: async () => ({}) });
|
||||
const result = await accountApi.sync();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(session.resetAndRedirect).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
|
||||
it("applies language, notification prefs and subscriptions from the fetched account", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
ok({
|
||||
language: "de",
|
||||
notification: { sound: "ding", delete_after: 3600, min_priority: 3 },
|
||||
subscriptions: [{ topic: "t" }],
|
||||
reservations: [{ topic: "t" }],
|
||||
})
|
||||
);
|
||||
|
||||
await accountApi.sync();
|
||||
|
||||
expect(i18n.changeLanguage).toHaveBeenCalledWith("de");
|
||||
expect(prefs.setSound).toHaveBeenCalledWith("ding");
|
||||
expect(prefs.setDeleteAfter).toHaveBeenCalledWith(3600);
|
||||
expect(prefs.setMinPriority).toHaveBeenCalledWith(3);
|
||||
expect(subscriptionManager.syncFromRemote).toHaveBeenCalledWith([{ topic: "t" }], [{ topic: "t" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userManager from "./UserManager";
|
||||
import api from "./Api";
|
||||
|
||||
// Api.js talks to the server through the global fetch and looks up credentials via userManager.
|
||||
// (vi.mock is hoisted above the imports by vitest, so the import above receives the mock.)
|
||||
vi.mock("./UserManager", () => ({ default: { get: vi.fn() } }));
|
||||
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
userManager.get.mockResolvedValue(undefined); // anonymous by default
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Api.poll", () => {
|
||||
it("parses newline-delimited JSON and skips lines without an id", async () => {
|
||||
const body = [
|
||||
JSON.stringify({ id: "1", message: "a" }),
|
||||
JSON.stringify({ event: "keepalive" }), // no id -> skipped
|
||||
JSON.stringify({ id: "2", message: "b" }),
|
||||
].join("\n");
|
||||
fetchMock.mockResolvedValue(new Response(body));
|
||||
|
||||
const messages = await api.poll("https://ntfy.sh", "mytopic");
|
||||
|
||||
expect(messages.map((m) => m.id)).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("uses the plain poll URL without a since cursor", async () => {
|
||||
fetchMock.mockResolvedValue(new Response(""));
|
||||
await api.poll("https://ntfy.sh", "mytopic");
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/mytopic/json?poll=1", expect.anything());
|
||||
});
|
||||
|
||||
it("uses the since URL when a cursor is given", async () => {
|
||||
fetchMock.mockResolvedValue(new Response(""));
|
||||
await api.poll("https://ntfy.sh", "mytopic", 12345);
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/mytopic/json?poll=1&since=12345", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Api.publish", () => {
|
||||
it("PUTs the message body to the base URL", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
await api.publish("https://ntfy.sh", "mytopic", "Hello", { priority: 5, tags: ["warning"] });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ topic: "mytopic", message: "Hello", priority: 5, tags: ["warning"] }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("attaches basic auth when the user has a password", async () => {
|
||||
userManager.get.mockResolvedValue({ username: "phil", password: "secret" });
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
await api.publish("https://ntfy.sh", "mytopic", "Hi");
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`);
|
||||
});
|
||||
|
||||
it("attaches bearer auth when the user has a token", async () => {
|
||||
userManager.get.mockResolvedValue({ token: "tk_abc" });
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
await api.publish("https://ntfy.sh", "mytopic", "Hi");
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.headers.Authorization).toBe("Bearer tk_abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Api.topicAuth", () => {
|
||||
it("returns true for a 2xx response", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
expect(await api.topicAuth("https://ntfy.sh", "mytopic")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for 401/403", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 403 });
|
||||
expect(await api.topicAuth("https://ntfy.sh", "mytopic")).toBe(false);
|
||||
});
|
||||
|
||||
it("throws for any other status", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 500 });
|
||||
await expect(api.topicAuth("https://ntfy.sh", "mytopic")).rejects.toThrow("Unexpected server response 500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Api web push", () => {
|
||||
const pushSubscription = {
|
||||
endpoint: "https://push.example/abc",
|
||||
keys: { auth: "AUTH", p256dh: "P256" },
|
||||
};
|
||||
|
||||
it("updateWebPush POSTs endpoint, keys and topics to the web push URL", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
await api.updateWebPush(pushSubscription, ["topicA", "topicB"]);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/v1/webpush",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ endpoint: "https://push.example/abc", auth: "AUTH", p256dh: "P256", topics: ["topicA", "topicB"] }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteWebPush DELETEs just the endpoint", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
await api.deleteWebPush(pushSubscription);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/v1/webpush",
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ endpoint: "https://push.example/abc" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,12 +11,12 @@ class Notifier {
|
||||
lastSoundPlayedAt = 0;
|
||||
|
||||
async notify(subscription, notification) {
|
||||
await this.playSound();
|
||||
|
||||
if (!this.supported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.playSound();
|
||||
|
||||
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||
const defaultTitle = topicDisplayName(subscription);
|
||||
|
||||
|
||||
+14
-5
@@ -6,6 +6,15 @@ export const THEME = {
|
||||
SYSTEM: "system",
|
||||
};
|
||||
|
||||
// Default values the getters return when a pref is unset; also used by PrefCache (PrefCache.jsx).
|
||||
export const PREF_DEFAULTS = {
|
||||
sound: "ding",
|
||||
minPriority: 1,
|
||||
deleteAfter: 604800, // one week
|
||||
theme: THEME.SYSTEM,
|
||||
webPushEnabled: false,
|
||||
};
|
||||
|
||||
class Prefs {
|
||||
constructor(dbImpl) {
|
||||
this.db = dbImpl;
|
||||
@@ -17,7 +26,7 @@ class Prefs {
|
||||
|
||||
async sound() {
|
||||
const sound = await this.db.prefs.get("sound");
|
||||
return sound ? sound.value : "ding";
|
||||
return sound ? sound.value : PREF_DEFAULTS.sound;
|
||||
}
|
||||
|
||||
async setMinPriority(minPriority) {
|
||||
@@ -26,7 +35,7 @@ class Prefs {
|
||||
|
||||
async minPriority() {
|
||||
const minPriority = await this.db.prefs.get("minPriority");
|
||||
return minPriority ? Number(minPriority.value) : 1;
|
||||
return minPriority ? Number(minPriority.value) : PREF_DEFAULTS.minPriority;
|
||||
}
|
||||
|
||||
async setDeleteAfter(deleteAfter) {
|
||||
@@ -35,12 +44,12 @@ class Prefs {
|
||||
|
||||
async deleteAfter() {
|
||||
const deleteAfter = await this.db.prefs.get("deleteAfter");
|
||||
return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
|
||||
return deleteAfter ? Number(deleteAfter.value) : PREF_DEFAULTS.deleteAfter;
|
||||
}
|
||||
|
||||
async webPushEnabled() {
|
||||
const webPushEnabled = await this.db.prefs.get("webPushEnabled");
|
||||
return webPushEnabled?.value;
|
||||
return webPushEnabled?.value ?? PREF_DEFAULTS.webPushEnabled;
|
||||
}
|
||||
|
||||
async setWebPushEnabled(enabled) {
|
||||
@@ -49,7 +58,7 @@ class Prefs {
|
||||
|
||||
async theme() {
|
||||
const theme = await this.db.prefs.get("theme");
|
||||
return theme?.value ?? THEME.SYSTEM;
|
||||
return theme?.value ?? PREF_DEFAULTS.theme;
|
||||
}
|
||||
|
||||
async setTheme(mode) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Dexie from "dexie";
|
||||
import { fadeOut } from "./transition";
|
||||
|
||||
/**
|
||||
* Manages the logged-in user's session and access token.
|
||||
@@ -42,7 +43,12 @@ class Session {
|
||||
localStorage.setItem("token", token);
|
||||
}
|
||||
|
||||
async resetAndRedirect(url) {
|
||||
async resetAndRedirect(url, { fade = false } = {}) {
|
||||
// User-initiated exits (logout, account deletion) fade out first, while data's intact, so the
|
||||
// wipe + reload doesn't flash a broken UI. Error redirects pass no options and cut straight through.
|
||||
if (fade) {
|
||||
await fadeOut();
|
||||
}
|
||||
await this.db.delete();
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("token");
|
||||
|
||||
@@ -6,7 +6,7 @@ import { topicUrl } from "./utils";
|
||||
import { messageWithSequenceId } from "./notificationUtils";
|
||||
import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events";
|
||||
|
||||
class SubscriptionManager {
|
||||
export class SubscriptionManager {
|
||||
constructor(dbImpl) {
|
||||
this.db = dbImpl;
|
||||
}
|
||||
@@ -65,23 +65,36 @@ class SubscriptionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a subscription: create it if it doesn't exist yet, or merge the given fields into the
|
||||
* existing one. Merging matters for account sync, which passes the remote display name and
|
||||
* reservation -- without it, reserving/unreserving a topic you're already subscribed to would
|
||||
* never be reflected locally until the database is recreated (e.g. a fresh login). Local-only
|
||||
* state such as mutedUntil and last is preserved on merge.
|
||||
*
|
||||
* @param {string} baseUrl
|
||||
* @param {string} topic
|
||||
* @param {object} opts
|
||||
* @param {boolean} opts.internal
|
||||
* @returns
|
||||
*/
|
||||
async add(baseUrl, topic, opts = {}) {
|
||||
async upsert(baseUrl, topic, opts = {}) {
|
||||
const id = topicUrl(baseUrl, topic);
|
||||
|
||||
const existingSubscription = await this.get(id);
|
||||
if (existingSubscription) {
|
||||
return existingSubscription;
|
||||
// Avoid a needless write (and the resulting Dexie live-query churn) when nothing changed.
|
||||
const changed = Object.keys(opts).some((key) => existingSubscription[key] !== opts[key]);
|
||||
if (!changed) {
|
||||
return existingSubscription;
|
||||
}
|
||||
const updatedSubscription = { ...existingSubscription, ...opts };
|
||||
await this.db.subscriptions.put(updatedSubscription);
|
||||
return updatedSubscription;
|
||||
}
|
||||
|
||||
const subscription = {
|
||||
...opts,
|
||||
id: topicUrl(baseUrl, topic),
|
||||
id,
|
||||
baseUrl,
|
||||
topic,
|
||||
mutedUntil: 0,
|
||||
@@ -101,7 +114,9 @@ class SubscriptionManager {
|
||||
remoteSubscriptions.map(async (remote) => {
|
||||
const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
|
||||
|
||||
const local = await this.add(remote.base_url, remote.topic, {
|
||||
// upsert(): for topics that already exist locally this merges in the latest remote
|
||||
// display name and reservation (see upsert() for why this matters).
|
||||
const local = await this.upsert(remote.base_url, remote.topic, {
|
||||
displayName: remote.display_name, // May be undefined
|
||||
reservation, // May be null!
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// SubscriptionManager pulls in a handful of browser/Dexie-heavy singletons at import time. Mock
|
||||
// them so the module imports cleanly under the node test environment; the tests construct their
|
||||
// own SubscriptionManager with an in-memory fake db, so the real db singleton is never used.
|
||||
vi.mock("./Api", () => ({ default: {} }));
|
||||
vi.mock("./Notifier", () => ({ default: {} }));
|
||||
vi.mock("./Prefs", () => ({ default: {} }));
|
||||
vi.mock("./db", () => ({ default: () => ({}) }));
|
||||
|
||||
const { SubscriptionManager } = await import("./SubscriptionManager");
|
||||
|
||||
// Minimal in-memory stand-in for the Dexie "subscriptions" table, implementing just the surface
|
||||
// that syncFromRemote() (and the upsert/remove/update helpers it calls) touches.
|
||||
const fakeDb = () => {
|
||||
const rows = new Map();
|
||||
return {
|
||||
rows,
|
||||
subscriptions: {
|
||||
get: async (id) => rows.get(id),
|
||||
put: async (sub) => {
|
||||
rows.set(sub.id, sub);
|
||||
},
|
||||
update: async (id, changes) => {
|
||||
const existing = rows.get(id);
|
||||
if (existing) {
|
||||
rows.set(id, { ...existing, ...changes });
|
||||
}
|
||||
},
|
||||
delete: async (id) => {
|
||||
rows.delete(id);
|
||||
},
|
||||
toArray: async () => Array.from(rows.values()),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const baseUrl = "https://ntfy.sh";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("SubscriptionManager.upsert", () => {
|
||||
it("merges fields into an existing subscription without clobbering local-only state", async () => {
|
||||
const db = fakeDb();
|
||||
const manager = new SubscriptionManager(db);
|
||||
|
||||
await manager.upsert(baseUrl, "mytopic");
|
||||
await manager.setMutedUntil("https://ntfy.sh/mytopic", 123);
|
||||
|
||||
const reservation = { topic: "mytopic", everyone: "deny-all" };
|
||||
await manager.upsert(baseUrl, "mytopic", { displayName: "My Topic", reservation });
|
||||
|
||||
const stored = db.rows.get("https://ntfy.sh/mytopic");
|
||||
expect(stored.reservation).toEqual(reservation);
|
||||
expect(stored.displayName).toBe("My Topic");
|
||||
expect(stored.mutedUntil).toBe(123); // local-only state preserved
|
||||
});
|
||||
|
||||
it("does not write when an existing subscription would not change", async () => {
|
||||
const db = fakeDb();
|
||||
const manager = new SubscriptionManager(db);
|
||||
|
||||
await manager.upsert(baseUrl, "mytopic", { internal: true });
|
||||
const putSpy = vi.spyOn(db.subscriptions, "put");
|
||||
|
||||
const result = await manager.upsert(baseUrl, "mytopic", { internal: true });
|
||||
|
||||
expect(putSpy).not.toHaveBeenCalled();
|
||||
expect(result.topic).toBe("mytopic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubscriptionManager.syncFromRemote", () => {
|
||||
it("persists a reservation onto a subscription that already exists locally", async () => {
|
||||
const db = fakeDb();
|
||||
const manager = new SubscriptionManager(db);
|
||||
|
||||
// Topic was subscribed to before it was reserved, so it already exists locally without a
|
||||
// reservation -- exactly the state when a user clicks "Reserve topic" in the navbar.
|
||||
await manager.upsert(baseUrl, "mytopic");
|
||||
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toBeFalsy();
|
||||
|
||||
const reservation = { topic: "mytopic", everyone: "deny-all" };
|
||||
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic" }], [reservation]);
|
||||
|
||||
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toEqual(reservation);
|
||||
});
|
||||
|
||||
it("clears the reservation when the remote no longer reports one", async () => {
|
||||
const db = fakeDb();
|
||||
const manager = new SubscriptionManager(db);
|
||||
|
||||
await manager.upsert(baseUrl, "mytopic", { reservation: { topic: "mytopic", everyone: "deny-all" } });
|
||||
|
||||
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic" }], []);
|
||||
|
||||
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toBeNull();
|
||||
});
|
||||
|
||||
it("updates the display name on an existing subscription", async () => {
|
||||
const db = fakeDb();
|
||||
const manager = new SubscriptionManager(db);
|
||||
|
||||
await manager.upsert(baseUrl, "mytopic");
|
||||
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic", display_name: "My Topic" }], []);
|
||||
|
||||
expect(db.rows.get("https://ntfy.sh/mytopic").displayName).toBe("My Topic");
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
UserExistsError,
|
||||
TopicReservedError,
|
||||
AccountActionLimitReachedError,
|
||||
IncorrectPasswordError,
|
||||
EmailVerificationCodeInvalidError,
|
||||
EmailPrimaryElsewhereError,
|
||||
throwAppError,
|
||||
fetchOrThrow,
|
||||
} from "./errors";
|
||||
|
||||
// A minimal stand-in for a fetch Response: just the bits errors.js reads.
|
||||
const fakeResponse = (status, body) => ({
|
||||
status,
|
||||
json: async () => {
|
||||
if (body === undefined) throw new Error("no body");
|
||||
return body;
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// errors.js logs to console.log on every error path; keep test output clean.
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("error classes", () => {
|
||||
it("carry the server error codes from errors.go", () => {
|
||||
expect(UserExistsError.CODE).toBe(40901);
|
||||
expect(TopicReservedError.CODE).toBe(40902);
|
||||
expect(AccountActionLimitReachedError.CODE).toBe(42906);
|
||||
expect(IncorrectPasswordError.CODE).toBe(40026);
|
||||
expect(EmailVerificationCodeInvalidError.CODE).toBe(40051);
|
||||
expect(EmailPrimaryElsewhereError.CODE).toBe(40908);
|
||||
});
|
||||
|
||||
it("are Error subclasses with human-readable messages", () => {
|
||||
expect(new UnauthorizedError()).toBeInstanceOf(Error);
|
||||
expect(new UnauthorizedError().message).toBe("Unauthorized");
|
||||
expect(new UserExistsError().message).toBe("Username already exists");
|
||||
});
|
||||
});
|
||||
|
||||
describe("throwAppError", () => {
|
||||
it("maps 401 and 403 to UnauthorizedError", async () => {
|
||||
await expect(throwAppError(fakeResponse(401))).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
await expect(throwAppError(fakeResponse(403))).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
});
|
||||
|
||||
it("maps known ntfy error codes to their specific error classes", async () => {
|
||||
await expect(throwAppError(fakeResponse(409, { code: UserExistsError.CODE }))).rejects.toBeInstanceOf(UserExistsError);
|
||||
await expect(throwAppError(fakeResponse(409, { code: TopicReservedError.CODE }))).rejects.toBeInstanceOf(TopicReservedError);
|
||||
await expect(throwAppError(fakeResponse(429, { code: AccountActionLimitReachedError.CODE }))).rejects.toBeInstanceOf(
|
||||
AccountActionLimitReachedError
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps an unknown code that carries an error message in a generic Error", async () => {
|
||||
await expect(throwAppError(fakeResponse(400, { code: 12345, error: "boom" }))).rejects.toThrow("Error 12345: boom");
|
||||
});
|
||||
|
||||
it("throws a generic 'Unexpected response' error when there is no ntfy error body", async () => {
|
||||
await expect(throwAppError(fakeResponse(500))).rejects.toThrow("Unexpected response 500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchOrThrow", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns the response unchanged on HTTP 200", async () => {
|
||||
const response = { status: 200 };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => response)
|
||||
);
|
||||
await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).resolves.toBe(response);
|
||||
});
|
||||
|
||||
it("throws on a non-200 response", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => fakeResponse(401))
|
||||
);
|
||||
await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ export const isImage = (attachment) => {
|
||||
};
|
||||
|
||||
export const icon = "/static/images/ntfy.png";
|
||||
export const badge = "/static/images/mask-icon.svg";
|
||||
export const badge = "/static/images/ntfy-mask.svg";
|
||||
|
||||
/**
|
||||
* Computes a unique notification tag scoped by baseUrl, topic, and sequence ID.
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import emojisMapped from "./emojisMapped";
|
||||
import { formatTitle, formatMessage, isImage, notificationTag, toNotificationParams, messageWithSequenceId } from "./notificationUtils";
|
||||
|
||||
// Pick a real alias/emoji pair so we don't hardcode a specific glyph that could change upstream.
|
||||
const [emojiAlias, emojiChar] = Object.entries(emojisMapped)[0];
|
||||
|
||||
describe("formatTitle", () => {
|
||||
it("returns the bare title when there are no emoji tags", () => {
|
||||
expect(formatTitle({ title: "Hello", tags: ["not-an-emoji"] })).toBe("Hello");
|
||||
expect(formatTitle({ title: "Hello" })).toBe("Hello");
|
||||
});
|
||||
|
||||
it("prepends mapped emoji for emoji tags", () => {
|
||||
expect(formatTitle({ title: "Hello", tags: [emojiAlias] })).toBe(`${emojiChar} Hello`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMessage", () => {
|
||||
it("returns the message untouched when the notification has a title", () => {
|
||||
expect(formatMessage({ title: "T", message: "Body", tags: [emojiAlias] })).toBe("Body");
|
||||
});
|
||||
|
||||
it("prepends emoji to the message when there is no title", () => {
|
||||
expect(formatMessage({ message: "Body", tags: [emojiAlias] })).toBe(`${emojiChar} Body`);
|
||||
});
|
||||
|
||||
it("falls back to an empty string when message is missing", () => {
|
||||
expect(formatMessage({})).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isImage", () => {
|
||||
it("trusts an explicit image MIME type", () => {
|
||||
expect(isImage({ type: "image/png", name: "whatever.txt" })).toBe(true);
|
||||
expect(isImage({ type: "application/pdf", name: "x.png" })).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the file extension when there is no type", () => {
|
||||
expect(isImage({ name: "photo.JPEG" })).toBeTruthy();
|
||||
expect(isImage({ url: "https://ntfy.sh/file/x.webp" })).toBeTruthy();
|
||||
expect(isImage({ name: "notes.txt" })).toBeFalsy();
|
||||
});
|
||||
|
||||
it("returns false for a missing attachment", () => {
|
||||
expect(isImage(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notificationTag", () => {
|
||||
it("scopes the tag by baseUrl, topic and sequence id", () => {
|
||||
expect(notificationTag("https://ntfy.sh", "mytopic", 42)).toBe("https://ntfy.sh/mytopic/42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toNotificationParams", () => {
|
||||
const baseMessage = {
|
||||
id: "msg-1",
|
||||
time: 1700000000,
|
||||
title: "Title",
|
||||
message: "Body",
|
||||
actions: [
|
||||
{ action: "view", label: "Open" },
|
||||
{ action: "http", label: "Send" },
|
||||
{ action: "broadcast", label: "Cast" },
|
||||
],
|
||||
};
|
||||
|
||||
it("builds the [title, options] tuple consumed by the Notifications API", () => {
|
||||
const [title, options] = toNotificationParams({
|
||||
message: baseMessage,
|
||||
defaultTitle: "fallback",
|
||||
topicRoute: "/mytopic",
|
||||
baseUrl: "https://ntfy.sh",
|
||||
topic: "mytopic",
|
||||
});
|
||||
|
||||
expect(title).toBe("Title");
|
||||
expect(options.body).toBe("Body");
|
||||
expect(options.tag).toBe("https://ntfy.sh/mytopic/msg-1");
|
||||
expect(options.timestamp).toBe(baseMessage.time * 1000);
|
||||
expect(options.data.subscriptionId).toBe("https://ntfy.sh/mytopic");
|
||||
expect(options.data.topicRoute).toBe("/mytopic");
|
||||
});
|
||||
|
||||
it("keeps only view/http actions and maps them to {action,title}", () => {
|
||||
const [, options] = toNotificationParams({
|
||||
message: baseMessage,
|
||||
defaultTitle: "fallback",
|
||||
topicRoute: "/mytopic",
|
||||
baseUrl: "https://ntfy.sh",
|
||||
topic: "mytopic",
|
||||
});
|
||||
expect(options.actions).toEqual([
|
||||
{ action: "Open", title: "Open" },
|
||||
{ action: "Send", title: "Send" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the default title when the message has none", () => {
|
||||
const [title] = toNotificationParams({
|
||||
message: { id: "x", time: 1, message: "Body" },
|
||||
defaultTitle: "fallback",
|
||||
topicRoute: "/mytopic",
|
||||
baseUrl: "https://ntfy.sh",
|
||||
topic: "mytopic",
|
||||
});
|
||||
expect(title).toBe("fallback");
|
||||
});
|
||||
|
||||
it("prefers sequence_id over id for the notification tag", () => {
|
||||
const [, options] = toNotificationParams({
|
||||
message: { ...baseMessage, sequence_id: 99 },
|
||||
defaultTitle: "fallback",
|
||||
topicRoute: "/mytopic",
|
||||
baseUrl: "https://ntfy.sh",
|
||||
topic: "mytopic",
|
||||
});
|
||||
expect(options.tag).toBe("https://ntfy.sh/mytopic/99");
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageWithSequenceId", () => {
|
||||
it("derives sequenceId from sequence_id when absent", () => {
|
||||
expect(messageWithSequenceId({ id: "a", sequence_id: 7 }).sequenceId).toBe(7);
|
||||
});
|
||||
|
||||
it("falls back to id when sequence_id is missing", () => {
|
||||
expect(messageWithSequenceId({ id: "a" }).sequenceId).toBe("a");
|
||||
});
|
||||
|
||||
it("returns the message unchanged when sequenceId already set", () => {
|
||||
const m = { id: "a", sequenceId: 1 };
|
||||
expect(messageWithSequenceId(m)).toBe(m);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// Fades out and removes the static splash (see web/index.html), called once the app has mounted and
|
||||
// its data is ready. Idempotent.
|
||||
|
||||
// Minimum time the splash stays up, so it doesn't flash-and-vanish on warm-cache loads.
|
||||
const MIN_VISIBLE_MS = 1000;
|
||||
|
||||
// Hide in two phases: fade the logo out, then fade the background away to reveal the app.
|
||||
// APP_FADE_MS must match the #splash opacity transition in index.html.
|
||||
const LOGO_FADE_MS = 300;
|
||||
const APP_FADE_MS = 100;
|
||||
|
||||
let removed = false;
|
||||
|
||||
const fadeOutAndRemove = () => {
|
||||
const splash = document.getElementById("splash");
|
||||
if (!splash) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 1: freeze the pulse at its current opacity (else stopping the animation snaps to full),
|
||||
// then fade the logo to 0.
|
||||
const img = splash.querySelector("img");
|
||||
if (img) {
|
||||
const current = getComputedStyle(img).opacity;
|
||||
img.style.opacity = current;
|
||||
img.style.animation = "none";
|
||||
img.getBoundingClientRect(); // force reflow so the fade starts from `current`
|
||||
img.style.transition = `opacity ${LOGO_FADE_MS}ms ease-out`;
|
||||
img.style.opacity = "0";
|
||||
}
|
||||
|
||||
// Phase 2: lift the background to fade the app in, then remove the node.
|
||||
setTimeout(() => {
|
||||
splash.classList.add("splash-hidden");
|
||||
const remove = () => splash.remove();
|
||||
// Ignore the logo's bubbling transitionend; only the background's own fade should remove it.
|
||||
const onEnd = (event) => {
|
||||
if (event.target === splash) {
|
||||
splash.removeEventListener("transitionend", onEnd);
|
||||
remove();
|
||||
}
|
||||
};
|
||||
splash.addEventListener("transitionend", onEnd);
|
||||
setTimeout(remove, APP_FADE_MS + 100); // fallback if transitionend never fires
|
||||
}, LOGO_FADE_MS);
|
||||
};
|
||||
|
||||
const hideSplash = () => {
|
||||
if (removed) {
|
||||
return;
|
||||
}
|
||||
removed = true;
|
||||
// performance.now() ~= how long the splash has been visible; hold until MIN_VISIBLE_MS elapses.
|
||||
const remaining = Math.max(0, MIN_VISIBLE_MS - performance.now());
|
||||
setTimeout(fadeOutAndRemove, remaining);
|
||||
};
|
||||
|
||||
export default hideSplash;
|
||||
@@ -0,0 +1,49 @@
|
||||
// Fade transitions between the app and the auth pages (login/signup/reset): fade #root out, then
|
||||
// navigate client-side (fading back in) or full-reload (the splash fades the next page in).
|
||||
|
||||
const FADE_MS = 150;
|
||||
const rootNode = () => document.getElementById("root");
|
||||
|
||||
// Fade #root out, resolving when done. Exported so callers can await it before their own
|
||||
// teardown + reload (e.g. resetAndRedirect wiping IndexedDB).
|
||||
export const fadeOut = () =>
|
||||
new Promise((resolve) => {
|
||||
const node = rootNode();
|
||||
if (!node) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
node.style.transition = `opacity ${FADE_MS}ms ease-out`;
|
||||
node.style.opacity = "0";
|
||||
setTimeout(resolve, FADE_MS);
|
||||
});
|
||||
|
||||
// Fade #root back in, then strip the inline styles (a lingering `transition` would animate future
|
||||
// opacity changes). setTimeout, not rAF, so a backgrounded tab can't strand #root at opacity 0.
|
||||
const fadeInRoot = () => {
|
||||
const node = rootNode();
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.style.opacity = "1";
|
||||
setTimeout(() => {
|
||||
node.style.transition = "";
|
||||
node.style.opacity = "";
|
||||
}, FADE_MS);
|
||||
};
|
||||
|
||||
// Fade out, navigate client-side, fade the new page in (app -> login/signup, no reload).
|
||||
export const fadeNavigate = (navigate, to) => {
|
||||
fadeOut().then(() => {
|
||||
navigate(to);
|
||||
fadeInRoot();
|
||||
});
|
||||
};
|
||||
|
||||
// Fade out, then full-reload to `url` (login/signup -> app needs a reload for the per-user DB).
|
||||
// The splash fades it back in.
|
||||
export const fadeReload = (url) => {
|
||||
fadeOut().then(() => {
|
||||
window.location.href = url;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { THEME } from "./Prefs";
|
||||
import {
|
||||
topicUrl,
|
||||
topicUrlWs,
|
||||
topicUrlJsonPollWithSince,
|
||||
accountUrl,
|
||||
accountTokenUrl,
|
||||
shortUrl,
|
||||
expandUrl,
|
||||
expandSecureUrl,
|
||||
validUrl,
|
||||
validTopic,
|
||||
disallowedTopic,
|
||||
encodeBase64,
|
||||
encodeBase64Url,
|
||||
bearerAuth,
|
||||
basicAuth,
|
||||
withBearerAuth,
|
||||
maybeWithAuth,
|
||||
splitNoEmpty,
|
||||
hashCode,
|
||||
formatBytes,
|
||||
formatNumber,
|
||||
formatPrice,
|
||||
formatShortDuration,
|
||||
getKebabCaseLangStr,
|
||||
darkModeEnabled,
|
||||
urlB64ToUint8Array,
|
||||
} from "./utils";
|
||||
|
||||
describe("URL builders", () => {
|
||||
it("build topic URLs", () => {
|
||||
expect(topicUrl("https://ntfy.sh", "mytopic")).toBe("https://ntfy.sh/mytopic");
|
||||
expect(topicUrlJsonPollWithSince("https://ntfy.sh", "mytopic", 123)).toBe("https://ntfy.sh/mytopic/json?poll=1&since=123");
|
||||
});
|
||||
|
||||
it("rewrite the scheme for websocket URLs", () => {
|
||||
expect(topicUrlWs("https://ntfy.sh", "mytopic")).toBe("wss://ntfy.sh/mytopic/ws");
|
||||
expect(topicUrlWs("http://localhost:8080", "mytopic")).toBe("ws://localhost:8080/mytopic/ws");
|
||||
});
|
||||
|
||||
it("build account URLs", () => {
|
||||
expect(accountUrl("https://ntfy.sh")).toBe("https://ntfy.sh/v1/account");
|
||||
expect(accountTokenUrl("https://ntfy.sh")).toBe("https://ntfy.sh/v1/account/token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("url helpers", () => {
|
||||
it("strip the scheme with shortUrl", () => {
|
||||
expect(shortUrl("https://ntfy.sh/mytopic")).toBe("ntfy.sh/mytopic");
|
||||
});
|
||||
|
||||
it("expand a bare host to both schemes", () => {
|
||||
expect(expandUrl("ntfy.sh")).toEqual(["https://ntfy.sh", "http://ntfy.sh"]);
|
||||
expect(expandSecureUrl("ntfy.sh")).toBe("https://ntfy.sh");
|
||||
});
|
||||
|
||||
it("validate http(s) URLs", () => {
|
||||
expect(validUrl("https://ntfy.sh")).toBeTruthy();
|
||||
expect(validUrl("http://ntfy.sh")).toBeTruthy();
|
||||
expect(validUrl("ftp://ntfy.sh")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("topic validation", () => {
|
||||
it("rejects disallowed topics (from config.disallowed_topics)", () => {
|
||||
expect(disallowedTopic("app")).toBe(true);
|
||||
expect(disallowedTopic("mytopic")).toBe(false);
|
||||
expect(validTopic("app")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts well-formed topic names and rejects malformed ones", () => {
|
||||
expect(validTopic("valid_Topic-123")).toBeTruthy();
|
||||
expect(validTopic("bad/slash")).toBeFalsy();
|
||||
expect(validTopic("with space")).toBeFalsy();
|
||||
expect(validTopic("")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("base64 + auth headers", () => {
|
||||
it("encodes base64 and base64url", () => {
|
||||
expect(encodeBase64("hello")).toBe("aGVsbG8=");
|
||||
expect(encodeBase64Url("hello")).toBe("aGVsbG8");
|
||||
});
|
||||
|
||||
it("builds bearer and basic auth headers", () => {
|
||||
expect(bearerAuth("tok")).toBe("Bearer tok");
|
||||
expect(basicAuth("phil", "secret")).toBe(`Basic ${encodeBase64("phil:secret")}`);
|
||||
expect(withBearerAuth({ "Content-Type": "application/json" }, "tok")).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer tok",
|
||||
});
|
||||
});
|
||||
|
||||
it("picks the right scheme in maybeWithAuth", () => {
|
||||
expect(maybeWithAuth({}, { username: "u", password: "p" }).Authorization).toBe(basicAuth("u", "p"));
|
||||
expect(maybeWithAuth({}, { token: "tok" }).Authorization).toBe(bearerAuth("tok"));
|
||||
expect(maybeWithAuth({ a: 1 }, undefined)).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("misc pure helpers", () => {
|
||||
it("splitNoEmpty trims and drops empty entries", () => {
|
||||
expect(splitNoEmpty("a, b, ,c ", ",")).toEqual(["a", "b", "c"]);
|
||||
expect(splitNoEmpty("", ",")).toEqual([]);
|
||||
});
|
||||
|
||||
it("hashCode is deterministic", () => {
|
||||
expect(hashCode("")).toBe(0);
|
||||
expect(hashCode("a")).toBe(97);
|
||||
expect(hashCode("hello")).toBe(hashCode("hello"));
|
||||
});
|
||||
|
||||
it("formatBytes is human readable", () => {
|
||||
expect(formatBytes(0)).toBe("0 bytes");
|
||||
expect(formatBytes(1024)).toBe("1 KB");
|
||||
expect(formatBytes(1536)).toBe("1.5 KB");
|
||||
});
|
||||
|
||||
it("formatNumber abbreviates round thousands", () => {
|
||||
expect(formatNumber(0)).toBe(0);
|
||||
expect(formatNumber(1000)).toBe("1k");
|
||||
expect(formatNumber(1500)).toBe((1500).toLocaleString());
|
||||
});
|
||||
|
||||
it("formatPrice renders cents as dollars", () => {
|
||||
expect(formatPrice(100)).toBe("$1");
|
||||
expect(formatPrice(150)).toBe("$1.5");
|
||||
});
|
||||
|
||||
it("formatShortDuration picks the largest fitting unit", () => {
|
||||
expect(formatShortDuration(60000, "en")).toContain("minute");
|
||||
expect(formatShortDuration(3600000, "en")).toContain("hour");
|
||||
});
|
||||
|
||||
it("getKebabCaseLangStr normalizes language tags", () => {
|
||||
expect(getKebabCaseLangStr("en_US")).toBe("en-US");
|
||||
expect(getKebabCaseLangStr(undefined)).toBe("en");
|
||||
expect(getKebabCaseLangStr("")).toBe("en");
|
||||
});
|
||||
|
||||
it("darkModeEnabled honors the theme preference", () => {
|
||||
expect(darkModeEnabled(false, THEME.DARK)).toBe(true);
|
||||
expect(darkModeEnabled(true, THEME.LIGHT)).toBe(false);
|
||||
expect(darkModeEnabled(true, THEME.SYSTEM)).toBe(true);
|
||||
expect(darkModeEnabled(false, THEME.SYSTEM)).toBe(false);
|
||||
});
|
||||
|
||||
it("urlB64ToUint8Array decodes web push keys", () => {
|
||||
expect(Array.from(urlB64ToUint8Array("AQID"))).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -41,14 +41,15 @@ import {
|
||||
} from "@mui/material";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import StarIcon from "@mui/icons-material/Star";
|
||||
import StarBorderIcon from "@mui/icons-material/StarBorder";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import CelebrationIcon from "@mui/icons-material/Celebration";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { ContentCopy, Public } from "@mui/icons-material";
|
||||
import ContentCopy from "@mui/icons-material/ContentCopy";
|
||||
import Public from "@mui/icons-material/Public";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import routes from "./routes";
|
||||
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, formatShortDuration, openUrl } from "../app/utils";
|
||||
@@ -56,7 +57,7 @@ import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus
|
||||
import { Pref, PrefGroup } from "./Pref";
|
||||
import db from "../app/db";
|
||||
import UpgradeDialog from "./UpgradeDialog";
|
||||
import { AccountContext } from "./App";
|
||||
import AccountContext from "./AccountContext";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import { Paragraph } from "./styles";
|
||||
import { EmailPrimaryElsewhereError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
||||
@@ -190,7 +191,7 @@ const ChangePasswordDialog = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
|
||||
<DialogTitle>{t("account_basics_password_dialog_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
@@ -401,13 +402,12 @@ const Emails = () => {
|
||||
setSnack(t("account_basics_emails_copied_to_clipboard"));
|
||||
};
|
||||
|
||||
// runEmailAction wraps an account API call with the shared error handling (redirect on
|
||||
// unauthorized, surface a message otherwise). On success it refetches the account so the email
|
||||
// list reflects the change immediately, rather than waiting for the async sync event.
|
||||
// runEmailAction wraps an email account action with the shared error handling used by the
|
||||
// delete/set-primary/resend handlers (redirect on unauthorized, surface a message otherwise).
|
||||
// The account view refreshes via the server's sync event, so there's no explicit refetch here.
|
||||
const runEmailAction = async (fn, errorMessage) => {
|
||||
try {
|
||||
await fn();
|
||||
await accountApi.sync();
|
||||
} catch (e) {
|
||||
console.log(`[Account] Email action failed`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
@@ -568,7 +568,6 @@ const AddEmailDialog = (props) => {
|
||||
setSending(true);
|
||||
setError(""); // Clear any error from a previous attempt
|
||||
await accountApi.startEmailVerification(email);
|
||||
await accountApi.sync(); // Refresh so the new "(unverified)" address shows up immediately
|
||||
setSent(true);
|
||||
} catch (e) {
|
||||
console.log(`[Account] Error starting email verification`, e);
|
||||
@@ -773,7 +772,7 @@ const AddPhoneNumberDialog = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
|
||||
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{t("account_basics_phone_numbers_dialog_description")}</DialogContentText>
|
||||
@@ -787,7 +786,7 @@ const AddPhoneNumberDialog = (props) => {
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
onChange={(ev) => setPhoneNumber(ev.target.value)}
|
||||
inputProps={{ inputMode: "tel", pattern: "+[0-9]*" }}
|
||||
slotProps={{ htmlInput: { inputMode: "tel", pattern: "+[0-9]*" } }}
|
||||
variant="standard"
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
@@ -818,7 +817,7 @@ const AddPhoneNumberDialog = (props) => {
|
||||
value={code}
|
||||
onChange={(ev) => setCode(ev.target.value)}
|
||||
fullWidth
|
||||
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
||||
slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } }}
|
||||
variant="standard"
|
||||
/>
|
||||
)}
|
||||
@@ -1397,7 +1396,7 @@ const DeleteAccountDialog = (props) => {
|
||||
await accountApi.delete(password);
|
||||
await db().delete();
|
||||
console.debug(`[Account] Account deleted`);
|
||||
await session.resetAndRedirect(routes.app);
|
||||
await session.resetAndRedirect(routes.app, { fade: true });
|
||||
} catch (e) {
|
||||
console.log(`[Account] Error deleting account`, e);
|
||||
if (e instanceof IncorrectPasswordError) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
const AccountContext = createContext(null);
|
||||
|
||||
export default AccountContext;
|
||||
@@ -6,15 +6,19 @@ import { useLocation, useNavigate } from "react-router-dom";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import NotificationsIcon from "@mui/icons-material/Notifications";
|
||||
import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
|
||||
import { Logout, Person, Settings } from "@mui/icons-material";
|
||||
import Logout from "@mui/icons-material/Logout";
|
||||
import Person from "@mui/icons-material/Person";
|
||||
import Settings from "@mui/icons-material/Settings";
|
||||
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 { fadeNavigate } from "../app/transition";
|
||||
import Navigation from "./Navigation";
|
||||
import accountApi from "../app/AccountApi";
|
||||
import PopupMenu from "./PopupMenu";
|
||||
@@ -88,6 +92,7 @@ const ActionBar = (props) => {
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{isLaunchedPWA && <ReloadIcon />}
|
||||
{props.selected && <SettingsIcons subscription={props.selected} onUnsubscribe={props.onUnsubscribe} />}
|
||||
<ProfileIcon />
|
||||
</Toolbar>
|
||||
@@ -124,6 +129,31 @@ const SettingsIcons = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
// ReloadIcon hard-refreshes the app. A plain reload would just serve the precached PWA shell,
|
||||
// so we first purge the service worker caches to force fresh assets from the network.
|
||||
const ReloadIcon = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleReload = async () => {
|
||||
try {
|
||||
if ("caches" in window) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[ActionBar] Error clearing caches during reload", e);
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleReload} aria-label={t("action_bar_reload")}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileIcon = () => {
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
@@ -143,7 +173,7 @@ const ProfileIcon = () => {
|
||||
await accountApi.logout();
|
||||
await db().delete();
|
||||
} finally {
|
||||
await session.resetAndRedirect(routes.app);
|
||||
await session.resetAndRedirect(routes.app, { fade: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -155,12 +185,23 @@ const ProfileIcon = () => {
|
||||
</IconButton>
|
||||
)}
|
||||
{!session.exists() && config.enable_login && (
|
||||
<Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{ m: 1 }} aria-label={t("action_bar_sign_in")}>
|
||||
<Button
|
||||
color="inherit"
|
||||
variant="text"
|
||||
onClick={() => fadeNavigate(navigate, routes.login)}
|
||||
sx={{ m: 1 }}
|
||||
aria-label={t("action_bar_sign_in")}
|
||||
>
|
||||
{t("action_bar_sign_in")}
|
||||
</Button>
|
||||
)}
|
||||
{!session.exists() && config.enable_signup && (
|
||||
<Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}>
|
||||
<Button
|
||||
color="inherit"
|
||||
variant="outlined"
|
||||
onClick={() => fadeNavigate(navigate, routes.signup)}
|
||||
aria-label={t("action_bar_sign_up")}
|
||||
>
|
||||
{t("action_bar_sign_up")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
+66
-31
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react";
|
||||
import { Suspense, useContext, useEffect, useState, useMemo } from "react";
|
||||
import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from "@mui/material";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
|
||||
@@ -27,11 +27,12 @@ import initI18n from "../app/i18n"; // Translations!
|
||||
import prefs from "../app/Prefs";
|
||||
import RTLCacheProvider from "./RTLCacheProvider";
|
||||
import session from "../app/Session";
|
||||
import AccountContext from "./AccountContext";
|
||||
import { PrefCacheProvider } from "./PrefCache";
|
||||
import hideSplash from "../app/splash";
|
||||
|
||||
initI18n();
|
||||
|
||||
export const AccountContext = createContext(null);
|
||||
|
||||
const App = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const languageDir = i18n.dir();
|
||||
@@ -39,16 +40,29 @@ const App = () => {
|
||||
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
|
||||
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
||||
const themePreference = useLiveQuery(() => prefs.theme());
|
||||
const theme = React.useMemo(
|
||||
() => createTheme({ ...(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), direction: languageDir }),
|
||||
[prefersDarkMode, themePreference, languageDir]
|
||||
);
|
||||
const isDark = darkModeEnabled(prefersDarkMode, themePreference);
|
||||
const theme = React.useMemo(() => createTheme({ ...(isDark ? darkTheme : lightTheme), direction: languageDir }), [isDark, languageDir]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("lang", getKebabCaseLangStr(i18n.language));
|
||||
document.dir = languageDir;
|
||||
}, [i18n.language, languageDir]);
|
||||
|
||||
// Keep the <html> background in sync with the theme once loaded. The splash script sets the
|
||||
// initial class; don't override it while the preference is still loading.
|
||||
useEffect(() => {
|
||||
if (themePreference === undefined) {
|
||||
return;
|
||||
}
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
}, [isDark, themePreference]);
|
||||
|
||||
// Safety net: hide the splash even if no route does (e.g. an unmatched path).
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => hideSplash(), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session.exists() && config.require_login && window.location.pathname !== routes.login) {
|
||||
window.location.href = routes.login;
|
||||
@@ -64,11 +78,13 @@ const App = () => {
|
||||
<CssBaseline />
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path={routes.login} element={<Login />} />
|
||||
<Route path={routes.signup} element={<Signup />} />
|
||||
<Route path={routes.passwordResetRequest} element={<PasswordResetRequest />} />
|
||||
<Route path={routes.passwordReset} element={<PasswordReset />} />
|
||||
<Route path={routes.emailVerify} element={<EmailVerify />} />
|
||||
<Route element={<AuthLayout />}>
|
||||
<Route path={routes.login} element={<Login />} />
|
||||
<Route path={routes.signup} element={<Signup />} />
|
||||
<Route path={routes.passwordResetRequest} element={<PasswordResetRequest />} />
|
||||
<Route path={routes.passwordReset} element={<PasswordReset />} />
|
||||
<Route path={routes.emailVerify} element={<EmailVerify />} />
|
||||
</Route>
|
||||
<Route element={<Layout />}>
|
||||
<Route path={routes.app} element={<AllSubscriptions />} />
|
||||
<Route path={routes.account} element={<Account />} />
|
||||
@@ -92,6 +108,12 @@ const updateTitle = (newNotificationsCount) => {
|
||||
updateFavicon(newNotificationsCount);
|
||||
};
|
||||
|
||||
// Auth pages render synchronously, so the splash can be removed on mount.
|
||||
const AuthLayout = () => {
|
||||
useEffect(() => hideSplash(), []);
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
const Layout = () => {
|
||||
const params = useParams();
|
||||
const { account, setAccount } = useContext(AccountContext);
|
||||
@@ -99,6 +121,9 @@ const Layout = () => {
|
||||
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
||||
const users = useLiveQuery(() => userManager.all());
|
||||
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
||||
// Preloaded here so the All view (and single topics, via filter) have data on mount -- no empty
|
||||
// frame when switching.
|
||||
const allNotifications = useLiveQuery(() => subscriptionManager.getAllNotifications());
|
||||
const webPushTopics = useWebPushTopics();
|
||||
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
|
||||
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
||||
@@ -113,27 +138,37 @@ const Layout = () => {
|
||||
useBackgroundProcesses();
|
||||
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
||||
|
||||
// Hide the splash only once subscriptions have loaded, so the nav/list don't pop in empty-then-filled.
|
||||
useEffect(() => {
|
||||
if (subscriptions !== undefined) {
|
||||
hideSplash();
|
||||
}
|
||||
}, [subscriptions]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
|
||||
<Navigation
|
||||
subscriptions={subscriptionsWithoutInternal}
|
||||
selectedSubscription={selected}
|
||||
mobileDrawerOpen={mobileDrawerOpen}
|
||||
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
||||
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
|
||||
/>
|
||||
<Main>
|
||||
<Toolbar />
|
||||
<Outlet
|
||||
context={{
|
||||
subscriptions: subscriptionsWithoutInternal,
|
||||
selected,
|
||||
}}
|
||||
<PrefCacheProvider>
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
|
||||
<Navigation
|
||||
subscriptions={subscriptionsWithoutInternal}
|
||||
selectedSubscription={selected}
|
||||
mobileDrawerOpen={mobileDrawerOpen}
|
||||
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
||||
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
|
||||
/>
|
||||
</Main>
|
||||
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
|
||||
</Box>
|
||||
<Main>
|
||||
<Toolbar />
|
||||
<Outlet
|
||||
context={{
|
||||
subscriptions: subscriptionsWithoutInternal,
|
||||
selected,
|
||||
allNotifications,
|
||||
}}
|
||||
/>
|
||||
</Main>
|
||||
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
|
||||
</Box>
|
||||
</PrefCacheProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { Avatar, Box, styled } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import logo from "../img/ntfy-filled.svg";
|
||||
import routes from "./routes";
|
||||
import { fadeNavigate } from "../app/transition";
|
||||
|
||||
const AvatarBoxContainer = styled(Box)`
|
||||
display: flex;
|
||||
@@ -13,11 +16,30 @@ const AvatarBoxContainer = styled(Box)`
|
||||
max-width: min(400px, 90dvw);
|
||||
margin: auto;
|
||||
`;
|
||||
const AvatarBox = (props) => (
|
||||
<AvatarBoxContainer>
|
||||
<Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />
|
||||
{props.children}
|
||||
</AvatarBoxContainer>
|
||||
);
|
||||
const AvatarBox = (props) => {
|
||||
const navigate = useNavigate();
|
||||
const avatar = <Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />;
|
||||
// Fade back to the app instead of a hard cut. Let modifier-clicks (open in new tab, etc.) through.
|
||||
const handleLogoClick = (ev) => {
|
||||
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
fadeNavigate(navigate, routes.app);
|
||||
};
|
||||
return (
|
||||
<AvatarBoxContainer>
|
||||
{/* The logo links back to the app, unless login is forced (no app to go back to without signing in) */}
|
||||
{config.require_login ? (
|
||||
avatar
|
||||
) : (
|
||||
<Box component="a" href={routes.app} onClick={handleLogoClick} sx={{ cursor: "pointer", lineHeight: 0 }}>
|
||||
{avatar}
|
||||
</Box>
|
||||
)}
|
||||
{props.children}
|
||||
</AvatarBoxContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvatarBox;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Typography, Button, Box, CircularProgress } from "@mui/material";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlineOutlined";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineOutlined";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import accountApi from "../app/AccountApi";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Typography, Box, TextField, ClickAwayListener, Fade, InputAdornment, styled, IconButton, Popper } from "@mui/material";
|
||||
import { Close } from "@mui/icons-material";
|
||||
import Close from "@mui/icons-material/Close";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { splitNoEmpty } from "../app/utils";
|
||||
import { rawEmojis } from "../app/emojis";
|
||||
@@ -71,18 +71,20 @@ const EmojiPicker = (props) => {
|
||||
variant="standard"
|
||||
fullWidth
|
||||
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
|
||||
inputProps={{
|
||||
role: "searchbox",
|
||||
"aria-label": t("emoji_picker_search_placeholder"),
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end" sx={{ display: search ? "" : "none" }}>
|
||||
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
role: "searchbox",
|
||||
"aria-label": t("emoji_picker_search_placeholder"),
|
||||
},
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end" sx={{ display: search ? "" : "none" }}>
|
||||
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
|
||||
@@ -4,12 +4,14 @@ import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Visibility, VisibilityOff } from "@mui/icons-material";
|
||||
import Visibility from "@mui/icons-material/Visibility";
|
||||
import VisibilityOff from "@mui/icons-material/VisibilityOff";
|
||||
import accountApi from "../app/AccountApi";
|
||||
import AvatarBox from "./AvatarBox";
|
||||
import session from "../app/Session";
|
||||
import routes from "./routes";
|
||||
import { UnauthorizedError } from "../app/errors";
|
||||
import { fadeReload } from "../app/transition";
|
||||
|
||||
const Login = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -25,7 +27,7 @@ const Login = () => {
|
||||
const token = await accountApi.login(user);
|
||||
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
|
||||
await session.store(user.username, token);
|
||||
window.location.href = routes.app;
|
||||
fadeReload(routes.app);
|
||||
} catch (e) {
|
||||
console.log(`[Login] User auth for user ${user.username} failed`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
@@ -66,21 +68,23 @@ const Login = () => {
|
||||
type={showPassword ? "text" : "password"}
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(ev) => setPassword(ev.target.value.trim())}
|
||||
onChange={(ev) => setPassword(ev.target.value)}
|
||||
autoComplete="current-password"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label={t("signup_form_toggle_password_visibility")}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
onMouseDown={(ev) => ev.preventDefault()}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label={t("signup_form_toggle_password_visibility")}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
onMouseDown={(ev) => ev.preventDefault()}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" fullWidth variant="contained" disabled={username === "" || password === ""} sx={{ mt: 2, mb: 2 }}>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useRemark } from "react-remark";
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
const MarkdownContainer = styled("div")`
|
||||
line-height: 1;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
pre,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
blockquote,
|
||||
pre {
|
||||
border-radius: 3px;
|
||||
background: ${(props) => (props.theme.palette.mode === "light" ? "#f5f5f5" : "#333")};
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: scroll;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
// Renders text/markdown notification bodies. react-remark (and its remark/unified
|
||||
// dependency stack) is heavy, so this component lives in its own module and is loaded
|
||||
// lazily by Notifications.jsx -- it only ships when a markdown message is actually shown.
|
||||
const MarkdownContent = ({ content }) => {
|
||||
const [reactContent, setMarkdownSource] = useRemark();
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdownSource(content);
|
||||
}, [content]);
|
||||
|
||||
return <MarkdownContainer>{reactContent}</MarkdownContainer>;
|
||||
};
|
||||
|
||||
export default MarkdownContent;
|
||||
@@ -22,12 +22,15 @@ import {
|
||||
} from "@mui/material";
|
||||
import * as React from "react";
|
||||
import { useContext, useState } from "react";
|
||||
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
|
||||
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutlineOutlined";
|
||||
import Person from "@mui/icons-material/Person";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
|
||||
import ChatBubble from "@mui/icons-material/ChatBubble";
|
||||
import MoreVert from "@mui/icons-material/MoreVert";
|
||||
import NotificationsOffOutlined from "@mui/icons-material/NotificationsOffOutlined";
|
||||
import Send from "@mui/icons-material/Send";
|
||||
import ArticleIcon from "@mui/icons-material/Article";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import CelebrationIcon from "@mui/icons-material/Celebration";
|
||||
@@ -41,7 +44,7 @@ import config from "../app/config";
|
||||
import session from "../app/Session";
|
||||
import accountApi, { Permission, Role } from "../app/AccountApi";
|
||||
import UpgradeDialog from "./UpgradeDialog";
|
||||
import { AccountContext } from "./App";
|
||||
import AccountContext from "./AccountContext";
|
||||
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
|
||||
import { SubscriptionPopup } from "./SubscriptionPopup";
|
||||
import { useNotificationPermissionListener, useVersionChangeListener } from "./hooks";
|
||||
|
||||
@@ -17,15 +17,12 @@ import {
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Suspense, lazy, useEffect, useMemo, 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 { useRemark } from "react-remark";
|
||||
import styled from "@emotion/styled";
|
||||
import {
|
||||
copyToClipboard,
|
||||
formatBytes,
|
||||
@@ -57,28 +54,25 @@ const priorityFiles = {
|
||||
};
|
||||
|
||||
export const AllSubscriptions = () => {
|
||||
const { subscriptions } = useOutletContext();
|
||||
if (!subscriptions) {
|
||||
return <Loading />;
|
||||
// allNotifications is preloaded in Layout, so this view has its data on mount (no empty frame on switch).
|
||||
const { subscriptions, allNotifications } = useOutletContext();
|
||||
if (!subscriptions || allNotifications === null || allNotifications === undefined) {
|
||||
return <DeferredLoading />;
|
||||
}
|
||||
return <AllSubscriptionsList subscriptions={subscriptions} />;
|
||||
return <AllSubscriptionsList subscriptions={subscriptions} notifications={allNotifications} />;
|
||||
};
|
||||
|
||||
export const SingleSubscription = () => {
|
||||
const { subscriptions, selected } = useOutletContext();
|
||||
const { subscriptions, selected, allNotifications } = useOutletContext();
|
||||
useAutoSubscribe(subscriptions, selected);
|
||||
if (!selected) {
|
||||
return <Loading />;
|
||||
if (!selected || allNotifications === null || allNotifications === undefined) {
|
||||
return <DeferredLoading />;
|
||||
}
|
||||
return <SingleSubscriptionList subscription={selected} />;
|
||||
return <SingleSubscriptionList subscription={selected} allNotifications={allNotifications} />;
|
||||
};
|
||||
|
||||
const AllSubscriptionsList = (props) => {
|
||||
const { subscriptions } = props;
|
||||
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
|
||||
if (notifications === null || notifications === undefined) {
|
||||
return <Loading />;
|
||||
}
|
||||
const { subscriptions, notifications } = props;
|
||||
if (subscriptions.length === 0) {
|
||||
return <NoSubscriptions />;
|
||||
}
|
||||
@@ -89,11 +83,13 @@ const AllSubscriptionsList = (props) => {
|
||||
};
|
||||
|
||||
const SingleSubscriptionList = (props) => {
|
||||
const { subscription } = props;
|
||||
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
|
||||
if (notifications === null || notifications === undefined) {
|
||||
return <Loading />;
|
||||
}
|
||||
const { subscription, allNotifications } = props;
|
||||
// Filter the preloaded allNotifications instead of a per-topic query (getNotifications(id) ==
|
||||
// getAllNotifications() filtered by id), so topic switches are instant.
|
||||
const notifications = useMemo(
|
||||
() => allNotifications.filter((notification) => notification.subscriptionId === subscription.id),
|
||||
[allNotifications, subscription.id]
|
||||
);
|
||||
if (notifications.length === 0) {
|
||||
return <NoNotifications subscription={subscription} />;
|
||||
}
|
||||
@@ -172,64 +168,19 @@ const autolink = (s) => {
|
||||
return <>{parts}</>;
|
||||
};
|
||||
|
||||
const MarkdownContainer = styled("div")`
|
||||
line-height: 1;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
pre,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
blockquote,
|
||||
pre {
|
||||
border-radius: 3px;
|
||||
background: ${(props) => (props.theme.palette.mode === "light" ? "#f5f5f5" : "#333")};
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: scroll;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const MarkdownContent = ({ content }) => {
|
||||
const [reactContent, setMarkdownSource] = useRemark();
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdownSource(content);
|
||||
}, [content]);
|
||||
|
||||
return <MarkdownContainer>{reactContent}</MarkdownContainer>;
|
||||
};
|
||||
// Loaded lazily so the heavy react-remark/unified markdown stack is only fetched when a
|
||||
// text/markdown notification is actually rendered (see MarkdownContent.jsx).
|
||||
const MarkdownContent = lazy(() => import("./MarkdownContent"));
|
||||
|
||||
const NotificationBody = ({ notification }) => {
|
||||
const displayAsMarkdown = notification.content_type === "text/markdown";
|
||||
const formatted = formatMessage(notification);
|
||||
if (displayAsMarkdown) {
|
||||
return <MarkdownContent content={formatted} />;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<MarkdownContent content={formatted} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
return autolink(formatted);
|
||||
};
|
||||
@@ -462,7 +413,7 @@ const Image = (props) => {
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
<Modal open={open} onClose={() => setOpen(false)} BackdropComponent={LightboxBackdrop}>
|
||||
<Modal open={open} onClose={() => setOpen(false)} slots={{ backdrop: LightboxBackdrop }}>
|
||||
<Fade in={open}>
|
||||
<Box
|
||||
component="img"
|
||||
@@ -716,3 +667,14 @@ const Loading = () => {
|
||||
</VerticallyCenteredContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// Render nothing until a load takes at least `delayMs`, so the centered spinner only shows on
|
||||
// genuinely slow loads -- normal sub-frame IndexedDB reads don't flash it on every remount.
|
||||
const DeferredLoading = ({ delayMs = 250 }) => {
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setShow(true), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delayMs]);
|
||||
return show ? <Loading /> : null;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Typography, TextField, Button, Box } from "@mui/material";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlineOutlined";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import accountApi from "../app/AccountApi";
|
||||
@@ -71,7 +71,7 @@ const PasswordReset = () => {
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(ev) => setPassword(ev.target.value.trim())}
|
||||
onChange={(ev) => setPassword(ev.target.value)}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -84,7 +84,7 @@ const PasswordReset = () => {
|
||||
type="password"
|
||||
id="confirm"
|
||||
value={confirm}
|
||||
onChange={(ev) => setConfirm(ev.target.value.trim())}
|
||||
onChange={(ev) => setConfirm(ev.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { TextField, Button, Box, Typography } from "@mui/material";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlineOutlined";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import accountApi from "../app/AccountApi";
|
||||
|
||||
@@ -10,30 +10,32 @@ const PopupMenu = (props) => {
|
||||
open={props.open}
|
||||
onClose={props.onClose}
|
||||
onClick={props.onClose}
|
||||
TransitionComponent={Fade}
|
||||
PaperProps={{
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: "visible",
|
||||
filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))",
|
||||
mt: 1.5,
|
||||
"& .MuiAvatar-root": {
|
||||
width: 32,
|
||||
height: 32,
|
||||
ml: -0.5,
|
||||
mr: 1,
|
||||
},
|
||||
"&:before": {
|
||||
content: '""',
|
||||
display: "block",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: "background.paper",
|
||||
transform: "translateY(-50%) rotate(45deg)",
|
||||
zIndex: 0,
|
||||
...arrow,
|
||||
slots={{ transition: Fade }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: "visible",
|
||||
filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))",
|
||||
mt: 1.5,
|
||||
"& .MuiAvatar-root": {
|
||||
width: 32,
|
||||
height: 32,
|
||||
ml: -0.5,
|
||||
mr: 1,
|
||||
},
|
||||
"&:before": {
|
||||
content: '""',
|
||||
display: "block",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: "background.paper",
|
||||
transform: "translateY(-50%) rotate(45deg)",
|
||||
zIndex: 0,
|
||||
...arrow,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react";
|
||||
import { createContext, useContext, useEffect } from "react";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import prefs, { PREF_DEFAULTS } from "../app/Prefs";
|
||||
|
||||
// A CACHE of the user's prefs -- not the source of truth (that's the `prefs` IndexedDB table, via
|
||||
// Prefs.js). Preloaded once in Layout so Settings renders instantly, and written through to
|
||||
// localStorage on every change so (a) Settings is instant even on a cold load and (b) the inline
|
||||
// splash script in index.html can read the theme synchronously before the bundle loads. The
|
||||
// "prefcache" key is duplicated in that script -- keep them in sync.
|
||||
const PREFCACHE_LOCALSTORAGE_KEY = "prefcache";
|
||||
|
||||
const PrefCacheContext = createContext(undefined);
|
||||
|
||||
// Synchronous fallback before the live query resolves. Merged over PREF_DEFAULTS so a newly-added
|
||||
// pref still has a value.
|
||||
const readPersistedCache = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFCACHE_LOCALSTORAGE_KEY);
|
||||
if (raw) {
|
||||
return { ...PREF_DEFAULTS, ...JSON.parse(raw) };
|
||||
}
|
||||
} catch (e) {
|
||||
// malformed or unavailable storage -- fall back to defaults
|
||||
}
|
||||
return PREF_DEFAULTS;
|
||||
};
|
||||
|
||||
export const PrefCacheProvider = ({ children }) => {
|
||||
const cache = useLiveQuery(async () => ({
|
||||
sound: await prefs.sound(),
|
||||
minPriority: await prefs.minPriority(),
|
||||
deleteAfter: await prefs.deleteAfter(),
|
||||
theme: await prefs.theme(),
|
||||
webPushEnabled: await prefs.webPushEnabled(),
|
||||
}));
|
||||
|
||||
// Write through to localStorage on change (prefs change rarely).
|
||||
useEffect(() => {
|
||||
if (cache !== undefined) {
|
||||
try {
|
||||
localStorage.setItem(PREFCACHE_LOCALSTORAGE_KEY, JSON.stringify(cache));
|
||||
} catch (e) {
|
||||
// localStorage may be unavailable (private mode) -- the cache just isn't persisted
|
||||
}
|
||||
}
|
||||
}, [cache]);
|
||||
|
||||
return <PrefCacheContext.Provider value={cache}>{children}</PrefCacheContext.Provider>;
|
||||
};
|
||||
|
||||
// Live context once resolved; else the synchronous localStorage snapshot (instant on cold load);
|
||||
// else defaults.
|
||||
export const usePrefCache = () => useContext(PrefCacheContext) ?? readPersistedCache();
|
||||
@@ -33,7 +33,7 @@ import CloseIcon from "@mui/icons-material/Close";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "@mui/icons-material";
|
||||
import Info from "@mui/icons-material/Info";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import userManager from "../app/UserManager";
|
||||
import { playSound, shortUrl, shuffle, sounds, validUrl } from "../app/utils";
|
||||
@@ -41,7 +41,7 @@ import session from "../app/Session";
|
||||
import routes from "./routes";
|
||||
import accountApi, { Permission, Role } from "../app/AccountApi";
|
||||
import { Pref, PrefGroup } from "./Pref";
|
||||
import { AccountContext } from "./App";
|
||||
import AccountContext from "./AccountContext";
|
||||
import { Paragraph } from "./styles";
|
||||
import prefs, { THEME } from "../app/Prefs";
|
||||
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
|
||||
@@ -50,6 +50,7 @@ import { UnauthorizedError } from "../app/errors";
|
||||
import { subscribeTopic } from "./SubscribeDialog";
|
||||
import notifier from "../app/Notifier";
|
||||
import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks";
|
||||
import { usePrefCache } from "./PrefCache";
|
||||
|
||||
const maybeUpdateAccountSettings = async (payload) => {
|
||||
if (!session.exists()) {
|
||||
@@ -99,7 +100,7 @@ const Notifications = () => {
|
||||
const Sound = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefSound";
|
||||
const sound = useLiveQuery(async () => prefs.sound());
|
||||
const { sound } = usePrefCache();
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setSound(ev.target.value);
|
||||
await maybeUpdateAccountSettings({
|
||||
@@ -108,9 +109,6 @@ const Sound = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
if (!sound) {
|
||||
return null; // While loading
|
||||
}
|
||||
let description;
|
||||
if (sound === "none") {
|
||||
description = t("prefs_notifications_sound_description_none");
|
||||
@@ -143,7 +141,7 @@ const Sound = () => {
|
||||
const MinPriority = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefMinPriority";
|
||||
const minPriority = useLiveQuery(async () => prefs.minPriority());
|
||||
const { minPriority } = usePrefCache();
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setMinPriority(ev.target.value);
|
||||
await maybeUpdateAccountSettings({
|
||||
@@ -152,9 +150,6 @@ const MinPriority = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
if (!minPriority) {
|
||||
return null; // While loading
|
||||
}
|
||||
const priorities = {
|
||||
1: t("priority_min"),
|
||||
2: t("priority_low"),
|
||||
@@ -191,7 +186,7 @@ const MinPriority = () => {
|
||||
const DeleteAfter = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefDeleteAfter";
|
||||
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
|
||||
const { deleteAfter } = usePrefCache();
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setDeleteAfter(ev.target.value);
|
||||
await maybeUpdateAccountSettings({
|
||||
@@ -201,11 +196,6 @@ const DeleteAfter = () => {
|
||||
});
|
||||
};
|
||||
|
||||
if (deleteAfter === null || deleteAfter === undefined) {
|
||||
// !deleteAfter will not work with "0"
|
||||
return null; // While loading
|
||||
}
|
||||
|
||||
const description = (() => {
|
||||
switch (deleteAfter) {
|
||||
case 0:
|
||||
@@ -241,7 +231,7 @@ const DeleteAfter = () => {
|
||||
const Theme = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefTheme";
|
||||
const theme = useLiveQuery(async () => prefs.theme());
|
||||
const { theme } = usePrefCache();
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setTheme(ev.target.value);
|
||||
};
|
||||
@@ -249,7 +239,7 @@ const Theme = () => {
|
||||
return (
|
||||
<Pref labelId={labelId} title={t("prefs_appearance_theme_title")}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={theme ?? THEME.SYSTEM} onChange={handleChange} aria-labelledby={labelId}>
|
||||
<Select value={theme} onChange={handleChange} aria-labelledby={labelId}>
|
||||
<MenuItem value={THEME.SYSTEM}>{t("prefs_appearance_theme_system")}</MenuItem>
|
||||
<MenuItem value={THEME.DARK}>{t("prefs_appearance_theme_dark")}</MenuItem>
|
||||
<MenuItem value={THEME.LIGHT}>{t("prefs_appearance_theme_light")}</MenuItem>
|
||||
@@ -262,7 +252,7 @@ const Theme = () => {
|
||||
const WebPushEnabled = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefWebPushEnabled";
|
||||
const enabled = useLiveQuery(async () => prefs.webPushEnabled());
|
||||
const { webPushEnabled: enabled } = usePrefCache();
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setWebPushEnabled(ev.target.value);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { Suspense, lazy, useContext, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Checkbox,
|
||||
Chip,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
|
||||
import { Close } from "@mui/icons-material";
|
||||
import Close from "@mui/icons-material/Close";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import priority1 from "../img/priority-1.svg";
|
||||
import priority2 from "../img/priority-2.svg";
|
||||
@@ -35,12 +35,15 @@ import AttachmentIcon from "./AttachmentIcon";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import api from "../app/Api";
|
||||
import userManager from "../app/UserManager";
|
||||
import EmojiPicker from "./EmojiPicker";
|
||||
import session from "../app/Session";
|
||||
import routes from "./routes";
|
||||
import accountApi from "../app/AccountApi";
|
||||
import { UnauthorizedError } from "../app/errors";
|
||||
import { AccountContext } from "./App";
|
||||
import AccountContext from "./AccountContext";
|
||||
|
||||
// Loaded lazily so the full emoji dataset (~300 KB) is only fetched when the publish
|
||||
// dialog is opened, not in the initial app bundle (see EmojiPicker.jsx).
|
||||
const EmojiPicker = lazy(() => import("./EmojiPicker"));
|
||||
|
||||
const PublishDialog = (props) => {
|
||||
const theme = useTheme();
|
||||
@@ -289,7 +292,7 @@ const PublishDialog = (props) => {
|
||||
return (
|
||||
<>
|
||||
{dropZone && <DropArea onDrop={handleAttachFileDrop} onDragLeave={handleAttachFileDragLeave} />}
|
||||
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||
<Dialog maxWidth="md" open={open} onClose={props.onClose} fullScreen={fullScreen}>
|
||||
<DialogTitle>
|
||||
{baseUrl && topic
|
||||
? t("publish_dialog_title_topic", {
|
||||
@@ -320,8 +323,10 @@ const PublishDialog = (props) => {
|
||||
type="url"
|
||||
variant="standard"
|
||||
sx={{ flexGrow: 1, marginRight: 1 }}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_base_url_label"),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": t("publish_dialog_base_url_label"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
@@ -335,8 +340,10 @@ const PublishDialog = (props) => {
|
||||
variant="standard"
|
||||
autoFocus={!messageFocused}
|
||||
sx={{ flexGrow: 1 }}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_topic_label"),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": t("publish_dialog_topic_label"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
@@ -351,8 +358,10 @@ const PublishDialog = (props) => {
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_title_label"),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": t("publish_dialog_title_label"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
@@ -368,8 +377,10 @@ const PublishDialog = (props) => {
|
||||
autoFocus={messageFocused}
|
||||
fullWidth
|
||||
multiline
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_message_label"),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": t("publish_dialog_message_label"),
|
||||
},
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
@@ -381,14 +392,18 @@ const PublishDialog = (props) => {
|
||||
size="small"
|
||||
checked={markdownEnabled}
|
||||
onChange={(ev) => setMarkdownEnabled(ev.target.checked)}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_checkbox_markdown"),
|
||||
slotProps={{
|
||||
input: {
|
||||
"aria-label": t("publish_dialog_checkbox_markdown"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div style={{ display: "flex" }}>
|
||||
<EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} />
|
||||
<Suspense fallback={null}>
|
||||
<EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} />
|
||||
</Suspense>
|
||||
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
|
||||
<InsertEmoticonIcon />
|
||||
</DialogIconButton>
|
||||
@@ -402,8 +417,10 @@ const PublishDialog = (props) => {
|
||||
type="text"
|
||||
variant="standard"
|
||||
sx={{ flexGrow: 1, marginRight: 1 }}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_tags_label"),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": t("publish_dialog_tags_label"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<FormControl variant="standard" margin="dense" sx={{ minWidth: 170, maxWidth: 300, flexGrow: 1 }}>
|
||||
@@ -460,8 +477,10 @@ const PublishDialog = (props) => {
|
||||
type="url"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_click_label"),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": t("publish_dialog_click_label"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
@@ -485,8 +504,10 @@ const PublishDialog = (props) => {
|
||||
type="email"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_email_label"),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": t("publish_dialog_email_label"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
@@ -556,8 +577,10 @@ const PublishDialog = (props) => {
|
||||
type="url"
|
||||
variant="standard"
|
||||
sx={{ flexGrow: 5, marginRight: 1 }}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_attach_label"),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": t("publish_dialog_attach_label"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
@@ -573,8 +596,10 @@ const PublishDialog = (props) => {
|
||||
type="text"
|
||||
variant="standard"
|
||||
sx={{ flexGrow: 1 }}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_filename_label"),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": t("publish_dialog_filename_label"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
@@ -617,8 +642,10 @@ const PublishDialog = (props) => {
|
||||
type="text"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_delay_label"),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": t("publish_dialog_delay_label"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
@@ -735,8 +762,10 @@ const PublishDialog = (props) => {
|
||||
size="small"
|
||||
checked={publishAnother}
|
||||
onChange={(ev) => setPublishAnother(ev.target.checked)}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_checkbox_publish_another"),
|
||||
slotProps={{
|
||||
input: {
|
||||
"aria-label": t("publish_dialog_checkbox_publish_another"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -867,12 +896,14 @@ const ExpandingTextField = (props) => {
|
||||
type="text"
|
||||
variant="standard"
|
||||
sx={{ width: `${textWidth}px`, borderBottom: "none" }}
|
||||
InputProps={{
|
||||
style: { fontSize: theme.typography[props.variant].fontSize },
|
||||
}}
|
||||
inputProps={{
|
||||
style: { paddingBottom: 0, paddingTop: 0 },
|
||||
"aria-label": props.placeholder,
|
||||
slotProps={{
|
||||
input: {
|
||||
style: { fontSize: theme.typography[props.variant].fontSize },
|
||||
},
|
||||
htmlInput: {
|
||||
style: { paddingBottom: 0, paddingTop: 0 },
|
||||
"aria-label": props.placeholder,
|
||||
},
|
||||
}}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, DeleteForever } from "@mui/icons-material";
|
||||
import Check from "@mui/icons-material/Check";
|
||||
import DeleteForever from "@mui/icons-material/DeleteForever";
|
||||
import { validTopic } from "../app/utils";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import session from "../app/Session";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Lock, Public } from "@mui/icons-material";
|
||||
import Lock from "@mui/icons-material/Lock";
|
||||
import Public from "@mui/icons-material/Public";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
export const PermissionReadWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} ref={ref} {...props} />);
|
||||
|
||||
@@ -4,12 +4,14 @@ import { TextField, Button, Box, Typography, InputAdornment, IconButton } from "
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import { Visibility, VisibilityOff } from "@mui/icons-material";
|
||||
import Visibility from "@mui/icons-material/Visibility";
|
||||
import VisibilityOff from "@mui/icons-material/VisibilityOff";
|
||||
import accountApi from "../app/AccountApi";
|
||||
import AvatarBox from "./AvatarBox";
|
||||
import session from "../app/Session";
|
||||
import routes from "./routes";
|
||||
import { AccountActionLimitReachedError, UserExistsError } from "../app/errors";
|
||||
import { fadeReload } from "../app/transition";
|
||||
|
||||
const Signup = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -29,7 +31,7 @@ const Signup = () => {
|
||||
const token = await accountApi.login(user);
|
||||
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
|
||||
await session.store(user.username, token);
|
||||
window.location.href = routes.app;
|
||||
fadeReload(routes.app);
|
||||
} catch (e) {
|
||||
console.log(`[Signup] Signup for user ${user.username} failed`, e);
|
||||
if (e instanceof UserExistsError) {
|
||||
@@ -87,20 +89,22 @@ const Signup = () => {
|
||||
id="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(ev) => setPassword(ev.target.value.trim())}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label={t("signup_form_toggle_password_visibility")}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
onMouseDown={(ev) => ev.preventDefault()}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
onChange={(ev) => setPassword(ev.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label={t("signup_form_toggle_password_visibility")}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
onMouseDown={(ev) => ev.preventDefault()}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
@@ -113,20 +117,22 @@ const Signup = () => {
|
||||
id="confirm"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(ev) => setConfirm(ev.target.value.trim())}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label={t("signup_form_toggle_password_visibility")}
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
onMouseDown={(ev) => ev.preventDefault()}
|
||||
edge="end"
|
||||
>
|
||||
{showConfirm ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
onChange={(ev) => setConfirm(ev.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label={t("signup_form_toggle_password_visibility")}
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
onMouseDown={(ev) => ev.preventDefault()}
|
||||
edge="end"
|
||||
>
|
||||
{showConfirm ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -26,7 +26,7 @@ import session from "../app/Session";
|
||||
import routes from "./routes";
|
||||
import accountApi, { Permission, Role } from "../app/AccountApi";
|
||||
import ReserveTopicSelect from "./ReserveTopicSelect";
|
||||
import { AccountContext } from "./App";
|
||||
import AccountContext from "./AccountContext";
|
||||
import { TopicReservedError, UnauthorizedError } from "../app/errors";
|
||||
import { ReserveLimitChip } from "./SubscriptionPopup";
|
||||
import prefs from "../app/Prefs";
|
||||
@@ -34,7 +34,7 @@ import prefs from "../app/Prefs";
|
||||
const publicBaseUrl = "https://ntfy.sh";
|
||||
|
||||
export const subscribeTopic = async (baseUrl, topic, opts) => {
|
||||
const subscription = await subscriptionManager.add(baseUrl, topic, opts);
|
||||
const subscription = await subscriptionManager.upsert(baseUrl, topic, opts);
|
||||
if (session.exists()) {
|
||||
try {
|
||||
await accountApi.addSubscription(baseUrl, topic);
|
||||
@@ -179,9 +179,11 @@ const SubscribePage = (props) => {
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
maxLength: 64,
|
||||
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder"),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
maxLength: 64,
|
||||
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@@ -202,8 +204,10 @@ const SubscribePage = (props) => {
|
||||
disabled={!reserveTopicEnabled}
|
||||
checked={reserveTopicVisible}
|
||||
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
|
||||
inputProps={{
|
||||
"aria-label": t("reserve_dialog_checkbox_label"),
|
||||
slotProps={{
|
||||
input: {
|
||||
"aria-label": t("reserve_dialog_checkbox_label"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -224,8 +228,10 @@ const SubscribePage = (props) => {
|
||||
<Switch
|
||||
onChange={handleUseAnotherChanged}
|
||||
checked={anotherServerVisible}
|
||||
inputProps={{
|
||||
"aria-label": t("subscribe_dialog_subscribe_use_another_label"),
|
||||
slotProps={{
|
||||
input: {
|
||||
"aria-label": t("subscribe_dialog_subscribe_use_another_label"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -303,8 +309,10 @@ const LoginPage = (props) => {
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
"aria-label": t("subscribe_dialog_login_username_label"),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": t("subscribe_dialog_login_username_label"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
@@ -316,8 +324,10 @@ const LoginPage = (props) => {
|
||||
onChange={(ev) => setPassword(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
"aria-label": t("subscribe_dialog_login_password_label"),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": t("subscribe_dialog_login_password_label"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
@@ -19,18 +19,16 @@ import {
|
||||
} from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Clear,
|
||||
ClearAll,
|
||||
Edit,
|
||||
EnhancedEncryption,
|
||||
Lock,
|
||||
LockOpen,
|
||||
Notifications,
|
||||
NotificationsOff,
|
||||
RemoveCircle,
|
||||
Send,
|
||||
} from "@mui/icons-material";
|
||||
import Clear from "@mui/icons-material/Clear";
|
||||
import ClearAll from "@mui/icons-material/ClearAll";
|
||||
import Edit from "@mui/icons-material/Edit";
|
||||
import EnhancedEncryption from "@mui/icons-material/EnhancedEncryption";
|
||||
import Lock from "@mui/icons-material/Lock";
|
||||
import LockOpen from "@mui/icons-material/LockOpen";
|
||||
import Notifications from "@mui/icons-material/Notifications";
|
||||
import NotificationsOff from "@mui/icons-material/NotificationsOff";
|
||||
import RemoveCircle from "@mui/icons-material/RemoveCircle";
|
||||
import Send from "@mui/icons-material/Send";
|
||||
import subscriptionManager from "../app/SubscriptionManager";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import accountApi, { Role } from "../app/AccountApi";
|
||||
@@ -39,7 +37,7 @@ import routes from "./routes";
|
||||
import PopupMenu from "./PopupMenu";
|
||||
import { formatShortDateTime, shuffle } from "../app/utils";
|
||||
import api from "../app/Api";
|
||||
import { AccountContext } from "./App";
|
||||
import AccountContext from "./AccountContext";
|
||||
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
|
||||
import { UnauthorizedError } from "../app/errors";
|
||||
|
||||
@@ -326,18 +324,20 @@ const DisplayNameDialog = (props) => {
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
maxLength: 64,
|
||||
"aria-label": t("display_name_dialog_placeholder"),
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setDisplayName("")} edge="end">
|
||||
<Clear />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
maxLength: 64,
|
||||
"aria-label": t("display_name_dialog_placeholder"),
|
||||
},
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setDisplayName("")} edge="end">
|
||||
<Clear />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
@@ -24,11 +24,12 @@ import {
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Check, Close } from "@mui/icons-material";
|
||||
import Check from "@mui/icons-material/Check";
|
||||
import Close from "@mui/icons-material/Close";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { UnauthorizedError } from "../app/errors";
|
||||
import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils";
|
||||
import { AccountContext } from "./App";
|
||||
import AccountContext from "./AccountContext";
|
||||
import routes from "./routes";
|
||||
import session from "../app/Session";
|
||||
import accountApi, { SubscriptionInterval } from "../app/AccountApi";
|
||||
|
||||
@@ -111,7 +111,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
|
||||
if (!account || !account.sync_topic) {
|
||||
return;
|
||||
}
|
||||
subscriptionManager.add(config.base_url, account.sync_topic, { internal: true }); // Dangle!
|
||||
subscriptionManager.upsert(config.base_url, account.sync_topic, { internal: true }); // Dangle!
|
||||
}, [account]);
|
||||
|
||||
// When subscriptions or users change, refresh the connections
|
||||
@@ -139,7 +139,7 @@ export const useAutoSubscribe = (subscriptions, selected) => {
|
||||
const baseUrl = params.baseUrl ? expandSecureUrl(params.baseUrl) : config.base_url;
|
||||
console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
|
||||
(async () => {
|
||||
const subscription = await subscriptionManager.add(baseUrl, params.topic);
|
||||
const subscription = await subscriptionManager.upsert(baseUrl, params.topic);
|
||||
if (session.exists()) {
|
||||
try {
|
||||
await accountApi.addSubscription(baseUrl, params.topic);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/* eslint-env node, es2021 */
|
||||
// Minimal browser-global stubs so the pure-logic and API modules import + run under the node
|
||||
// environment, without pulling in jsdom. utils.js -> config.js does `const { config } = window;`
|
||||
// at import time, and config.js falls back to window.location.origin when base_url is empty.
|
||||
const config = { base_url: "https://ntfy.sh", disallowed_topics: ["app", "account", "settings"] };
|
||||
|
||||
globalThis.window = {
|
||||
location: { origin: "https://ntfy.sh" },
|
||||
config,
|
||||
atob: globalThis.atob, // urlB64ToUint8Array uses window.atob; Node provides global atob
|
||||
};
|
||||
|
||||
// In the browser, `window` IS the global object, so `window.config` is also reachable as a bare
|
||||
// `config` identifier -- Api.js/AccountApi.js/UserManager.js/SubscriptionManager.js rely on that.
|
||||
// Node's `window` is just a plain object, so expose the same object as a real global too.
|
||||
globalThis.config = config;
|
||||
|
||||
// utils.js -> Prefs.js -> db.js -> Session.username() reads localStorage at module load.
|
||||
// Node has no localStorage; an in-memory stand-in is enough for the pure-logic tests.
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => (store.has(key) ? store.get(key) : null),
|
||||
setItem: (key, value) => store.set(key, String(value)),
|
||||
removeItem: (key) => store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Standalone config (separate from vite.config.js) so the PWA plugin isn't loaded during tests.
|
||||
// Tests run in the default "node" environment -- see src/test/setup.js for the minimal window stub
|
||||
// that lets the pure-logic modules import without jsdom.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
setupFiles: ["./src/test/setup.js"],
|
||||
include: ["src/**/*.test.{js,jsx}"],
|
||||
globals: false, // explicit `import { describe, it, expect } from "vitest"` -> no eslint env change
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user