Compare commits

...

58 Commits

Author SHA1 Message Date
binwiederhier
2697600111 Merge branch 'main' into web-improvements 2023-05-24 22:28:53 -04:00
binwiederhier
c16da26780 Release notes 2023-05-24 22:28:26 -04:00
binwiederhier
c50633d990 Deps 2023-05-24 22:18:10 -04:00
binwiederhier
517341b5d7 Re-add @emotion due to build errors 2023-05-24 22:15:46 -04:00
binwiederhier
e1dd0c64e2 Merge branch 'main' into switch-to-vite 2023-05-24 21:59:14 -04:00
binwiederhier
e7bf165934 Formatting 2023-05-24 21:59:04 -04:00
binwiederhier
a90bd4cd06 Formatting, npm update 2023-05-24 21:44:12 -04:00
binwiederhier
d1e59fe08c Merge branch 'main' into switch-to-vite 2023-05-24 21:37:28 -04:00
binwiederhier
6bb5274d83 Release notes 2023-05-24 21:34:25 -04:00
binwiederhier
b7c121e78e Revert inputProps things 2023-05-24 21:32:15 -04:00
binwiederhier
1251a4adab Merge branch 'main' into add-eslint 2023-05-24 21:31:53 -04:00
binwiederhier
4cacc02520 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-05-24 20:37:47 -04:00
nimbleghost
025ea3c1d6 Make small code style improvements 2023-05-24 22:18:06 +02:00
nimbleghost
be6d962cc3 Fix param reassignment issue 2023-05-24 22:18:06 +02:00
nimbleghost
4a0a22566a Fix jsx key issue 2023-05-24 22:18:06 +02:00
nimbleghost
25d6725d8f Use es6 destructuring swap for shuffling 2023-05-24 22:18:06 +02:00
nimbleghost
1291c3afe9 Make async for loops performant using Promise.all 2023-05-24 22:18:06 +02:00
nimbleghost
d625a003b8 Use esm mui imports for Vite compatibility
See: https://github.com/mui/material-ui/issues/31835#issuecomment-1153393901
2023-05-24 22:16:10 +02:00
nimbleghost
e21327cec5 Add vite
Changes according to Vite defaults:

- Move index.html to root
- Replace `%PUBLIC_URL%` with plain `/`
2023-05-24 22:16:10 +02:00
nimbleghost
7ccc5be9b4 Fix jsx key issue 2023-05-24 21:10:09 +02:00
nimbleghost
9ebeb7f12f Fix mui inputProps 2023-05-24 21:08:33 +02:00
Andrew
d3be1fa359 Translated using Weblate (Ukrainian)
Currently translated at 92.9% (354 of 381 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2023-05-24 16:49:12 +02:00
Enzo Salson
e3d530cb90 Translated using Weblate (French)
Currently translated at 97.3% (371 of 381 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2023-05-24 16:49:11 +02:00
nimbleghost
951c90763a Add eslint commits to .git-blame-ignore-revs 2023-05-24 12:58:49 +02:00
nimbleghost
59011c8a32 Make manual eslint fixes
These are safe fixes, more complicated fixes can be done separately
(just disabled those errors for now).

- Reorder declarations to fix `no-use-before-define`
- Rename parameters for `no-shadow`
- Remove unused parameters, functions, imports
- Switch from `++` and `—` to `+= 1` and `-= 1` for `no-unary`
- Use object spreading instead of parameter reassignment in auth utils
- Use `window.location` instead of `location` global
- Use inline JSX strings instead of unescaped values
-
2023-05-24 12:58:48 +02:00
nimbleghost
8319f1cf26 Run eslint autofixes 2023-05-24 12:51:53 +02:00
nimbleghost
f558b4dbe9 Add .jsx filename extension
(This is also required for Vite later)
2023-05-24 12:51:53 +02:00
nimbleghost
d7eb1206fe Add eslint with eslint-config-airbnb 2023-05-24 12:51:52 +02:00
binwiederhier
fa29da1a32 Release notes 2023-05-23 20:19:17 -04:00
binwiederhier
a64e365add Update .git-blame-ignore-revs 2023-05-23 20:18:03 -04:00
binwiederhier
c87549e71a Width, again 2023-05-23 20:16:29 -04:00
binwiederhier
ca5d736a71 Line width 2023-05-23 19:29:47 -04:00
binwiederhier
2e27f58963 Merge branch 'main' into add-prettier 2023-05-23 19:23:58 -04:00
binwiederhier
6f230a796e Release notes 2023-05-23 19:23:34 -04:00
Philipp C. Heckel
9e44db78a2 Merge pull request #745 from nimbleghost/update-actions
Update GitHub Actions
2023-05-23 19:17:12 -04:00
nimbleghost
a859ed9f58 Add .git-blame-ignore-revs 2023-05-23 21:13:49 +02:00
nimbleghost
6f6a2d1f69 Run prettier 2023-05-23 21:13:17 +02:00
nimbleghost
206ea312bf Add prettier 2023-05-23 21:12:25 +02:00
nimbleghost
3f8784c8a8 Move checkout up since the cache needs lockfiles 2023-05-23 21:03:58 +02:00
nimbleghost
1761ec0207 Move react-scripts to devDependencies 2023-05-23 20:52:56 +02:00
nimbleghost
ceedca4e27 Update GitHub Actions
- Use the newest versions to solve the deprecation warning
- Remove the cache step as the newest go and node actions have built-in
  caching
- Add the official actions@github.com email address
2023-05-23 20:50:20 +02:00
binwiederhier
ffbf288c9b Merge branch 'main' of github.com:binwiederhier/ntfy 2023-05-23 14:24:16 -04:00
binwiederhier
f8a00dd411 Fix test 2023-05-23 14:24:11 -04:00
Philipp C. Heckel
6a5b5b3763 Merge pull request #743 from nimbleghost/remove-unused-packages
[web] remove unused @emotion packages
2023-05-23 14:19:02 -04:00
nimbleghost
6bd4c8fb71 [web] remove unused @emotion packages 2023-05-23 20:16:38 +02:00
binwiederhier
df2872bebd Merge branch 'main' of github.com:binwiederhier/ntfy 2023-05-23 13:21:12 -04:00
binwiederhier
0393145f42 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-05-23 13:21:05 -04:00
binwiederhier
da06ae4485 Clarify error message for poll requests 2023-05-23 13:20:43 -04:00
Philipp C. Heckel
e10442f6ca Merge pull request #739 from ksurl/token-query-param
docs: generating query param for access token
2023-05-22 21:23:21 -04:00
ksurl
5379474c41 add docs for generating query param for access token 2023-05-23 01:20:56 +00:00
binwiederhier
168ad8bf1b Support encoding any header as RFC 2047 2023-05-21 20:56:56 -04:00
Linerly
89cf84b63e Translated using Weblate (Indonesian)
Currently translated at 100.0% (381 of 381 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2023-05-22 00:52:00 +02:00
binwiederhier
b3a299ce22 You rock Jonathan. Thank you for your sponsorship @jonathan-kosgei 2023-05-21 17:27:24 -04:00
binwiederhier
7838b253b4 Android release notes 2023-05-21 17:26:29 -04:00
Andrew
7140f18574 Translated using Weblate (Ukrainian)
Currently translated at 77.9% (297 of 381 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2023-05-20 14:50:45 +02:00
Shoshin Akamine
5345b9063c Translated using Weblate (Japanese)
Currently translated at 93.9% (358 of 381 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ja/
2023-05-20 14:50:45 +02:00
binwiederhier
4ad0fb1f57 Fix docs ToC parsing issue 2023-05-19 09:25:25 -04:00
binwiederhier
57eabd3aa5 Thank you @darkdragon-001 for your donation 2023-05-18 15:08:40 -04:00
98 changed files with 23590 additions and 21164 deletions

11
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1,11 @@
# https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view
# Run prettier (https://github.com/binwiederhier/ntfy/pull/746)
6f6a2d1f693070bf72e89d86748080e4825c9164
c87549e71a10bc789eac8036078228f06e515a8e
ca5d736a7169eb6b4b0d849e061d5bf9565dcc53
2e27f58963feb9e4d1c573d4745d07770777fa7d
# Run eslint (https://github.com/binwiederhier/ntfy/pull/748)
f558b4dbe9bb5b9e0e87fada1215de2558353173
8319f1cf26113167fb29fe12edaff5db74caf35f

View File

@@ -4,30 +4,21 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
-
name: Checkout code
uses: actions/checkout@v3
-
name: Install Go
uses: actions/setup-go@v2
uses: actions/setup-go@v4
with:
go-version: '1.19.x'
-
name: Install node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: '18'
-
name: Checkout code
uses: actions/checkout@v2
-
name: Cache Go and npm modules
uses: actions/cache@v3
with:
path: |
~/go/pkg/mod
~/go/bin
~/.npm
web/node_modules
key: ${{ runner.os }}-ntfy-${{ hashFiles('go.sum', 'web/package.lock') }}
restore-keys: ${{ runner.os }}-ntfy-
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
-
name: Install dependencies
run: make build-deps-ubuntu

View File

@@ -30,7 +30,7 @@ jobs:
run: |
cd build/ntfy-docs.github.io
git config user.name "GitHub Actions Bot"
git config user.email "<>"
git config user.email "<actions@github.com>"
git add docs/
git commit -m "Updated docs"
git push origin main

View File

@@ -7,30 +7,21 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
-
name: Checkout code
uses: actions/checkout@v3
-
name: Install Go
uses: actions/setup-go@v2
uses: actions/setup-go@v4
with:
go-version: '1.19.x'
-
name: Install node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: '18'
-
name: Checkout code
uses: actions/checkout@v2
-
name: Cache Go and npm modules
uses: actions/cache@v3
with:
path: |
~/go/pkg/mod
~/go/bin
~/.npm
web/node_modules
key: ${{ runner.os }}-ntfy-${{ hashFiles('go.sum', 'web/package.lock') }}
restore-keys: ${{ runner.os }}-ntfy-
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
-
name: Docker login
uses: docker/login-action@v2

View File

@@ -4,30 +4,21 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
-
name: Checkout code
uses: actions/checkout@v3
-
name: Install Go
uses: actions/setup-go@v2
uses: actions/setup-go@v4
with:
go-version: '1.19.x'
-
name: Install node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: '18'
-
name: Checkout code
uses: actions/checkout@v2
-
name: Cache Go and npm modules
uses: actions/cache@v3
with:
path: |
~/go/pkg/mod
~/go/bin
~/.npm
web/node_modules
key: ${{ runner.os }}-ntfy-${{ hashFiles('go.sum', 'web/package.lock') }}
restore-keys: ${{ runner.os }}-ntfy-
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
-
name: Install dependencies
run: make build-deps-ubuntu

View File

@@ -35,6 +35,8 @@ help:
@echo " make web - Build the web app"
@echo " make web-deps - Install web app dependencies (npm install the universe)"
@echo " make web-build - Actually build the web app"
@echo " make web-format - Run prettier on the web app
@echo " make web-format-check - Run prettier on the web app, but don't change anything
@echo
@echo "Build documentation:"
@echo " make docs - Build the documentation"
@@ -127,8 +129,7 @@ web-build:
&& rm -rf ../server/site \
&& mv build ../server/site \
&& rm \
../server/site/config.js \
../server/site/asset-manifest.json
../server/site/config.js
web-deps:
cd web && npm install
@@ -137,6 +138,14 @@ web-deps:
web-deps-update:
cd web && npm update
web-format:
cd web && npm run format
web-format-check:
cd web && npm run format:check
web-lint:
cd web && npm run lint
# Main server/client build
@@ -226,7 +235,7 @@ cli-build-results:
# Test/check targets
check: test fmt-check vet lint staticcheck
check: test web-format-check fmt-check vet web-lint lint staticcheck
test: .PHONY
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')

View File

@@ -139,6 +139,8 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
<a href="https://github.com/andrejarrell"><img src="https://github.com/andrejarrell.png" width="40px" /></a>
<a href="https://github.com/oaustegard"><img src="https://github.com/oaustegard.png" width="40px" /></a>
<a href="https://github.com/CreativeWarlock"><img src="https://github.com/CreativeWarlock.png" width="40px" /></a>
<a href="https://github.com/darkdragon-001"><img src="https://github.com/darkdragon-001.png" width="40px" /></a>
<a href="https://github.com/jonathan-kosgei"><img src="https://github.com/jonathan-kosgei.png" width="40px" /></a>
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:

View File

@@ -393,8 +393,8 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
!!! info
ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the `X-Title` or `X-Message`
header as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including the title)
as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
## Message priority
@@ -619,7 +619,7 @@ them with a comma, e.g. `tag1,tag2,tag3`.
!!! info
ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the individual tags
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the tags header or individual tags
as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `tag1,=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
or `=?UTF-8?Q?=C3=84pfel?=,tag2` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
@@ -1004,9 +1004,11 @@ all the supported fields:
| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) |
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
## Action buttons
_Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -1139,7 +1141,13 @@ As an example, here's how you can create the above notification using this forma
]
]));
```
!!! info
ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including actions)
as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
#### Using a JSON array
Alternatively, the same actions can be defined as **JSON array**, if the notification is defined as part of the JSON body
(see [publish as JSON](#publish-as-json)):
@@ -2903,6 +2911,7 @@ Here's an example with a user `testuser` and password `fakepassword`:
```
=== "PowerShell 5 and earlier"
``` powershell
# With PowerShell 5 or earlier, we need to create the base64 username:password string ourselves
$CredentialString = "$($Credential.Username):$($Credential.GetNetworkCredential().Password)"
$EncodedCredential = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CredentialString))
@@ -3229,6 +3238,12 @@ The following command will generate the appropriate value for you on *nix system
echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '='
```
For access tokens, you can use this instead:
```
echo -n "Bearer faketoken" | base64 | tr -d '='
```
## Advanced features
### Message caching
@@ -3464,7 +3479,7 @@ table in their canonical form.
!!! info
ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the `X-Title` or `X-Message`
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any
header as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).

View File

@@ -28,7 +28,7 @@ if you use promo code `MYTOPIC`). ntfy will always remain open source.
* Attachments with filenames that are downloaded using a browser will now download with the proper filename ([#726](https://github.com/binwiederhier/ntfy/issues/726), thanks to [@un99known99](https://github.com/un99known99) for reporting, and [@wunter8](https://github.com/wunter8) for fixing)
* Fix web app i18n issue in account preferences ([#730](https://github.com/binwiederhier/ntfy/issues/730), thanks to [@codebude](https://github.com/codebude) for reporting)
### ntfy server v2.4.0
## ntfy server v2.4.0
Released Apr 26, 2023
This release adds a tiny `v1/stats` endpoint to expose how many messages have been published, and adds suport to encode the `X-Title`,
@@ -57,7 +57,7 @@ will always remain open source.
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/Shjosan/))
### ntfy server v2.3.1
## ntfy server v2.3.1
Released March 30, 2023
This release disables server-initiated polling of iOS devices entirely, thereby eliminating the thundering herd problem
@@ -1214,7 +1214,21 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
* Bumped all dependencies to the latest versions (no ticket)
**Additional languages:**
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
### ntfy server v2.6.0 (UNRELEASED)
**Bug fixes:**
* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting)
**Maintenance:**
* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost))
* Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost))
* Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), thanks to [@nimbleghost](https://github.com/nimbleghost))
* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost))

View File

@@ -868,7 +868,11 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
logvm(v, m).Err(err).Warn("Unable to publish poll request")
return
} else if response.StatusCode != http.StatusOK {
logvm(v, m).Err(err).Warn("Unable to publish poll request, unexpected HTTP status: %d", response.StatusCode)
if response.StatusCode == http.StatusTooManyRequests {
logvm(v, m).Err(err).Warn("Unable to publish poll request, the upstream server %s responded with HTTP %s; you may solve this by sending fewer daily messages, or by configuring upstream-access-token (assuming you have an account with higher rate limits) ", s.config.UpstreamBaseURL, response.Status)
} else {
logvm(v, m).Err(err).Warn("Unable to publish poll request, the upstream server %s responded with HTTP %s", s.config.UpstreamBaseURL, response.Status)
}
return
}
}
@@ -876,7 +880,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) {
cache = readBoolParam(r, true, "x-cache", "cache")
firebase = readBoolParam(r, true, "x-firebase", "firebase")
m.Title = maybeDecodeHeader(readParam(r, "x-title", "title", "t"))
m.Title = readParam(r, "x-title", "title", "t")
m.Click = readParam(r, "x-click", "click")
icon := readParam(r, "x-icon", "icon")
filename := readParam(r, "x-filename", "filename", "file", "f")
@@ -923,7 +927,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
}
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
if messageStr != "" {
m.Message = maybeDecodeHeader(messageStr)
m.Message = messageStr
}
var e error
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
@@ -931,9 +935,6 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
return false, false, "", "", false, errHTTPBadRequestPriorityInvalid
}
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
for i, t := range m.Tags {
m.Tags[i] = maybeDecodeHeader(t)
}
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
if delayStr != "" {
if !cache {
@@ -1747,6 +1748,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
if m.Delay != "" {
r.Header.Set("X-Delay", m.Delay)
}
if m.Call != "" {
r.Header.Set("X-Call", m.Call)
}
return next(w, r, v)
}
}

View File

@@ -219,7 +219,7 @@ func TestServer_StaticSites(t *testing.T) {
rr = request(t, s, "GET", "/mytopic", "", nil)
require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow"/>`)
require.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow" />`)
rr = request(t, s, "GET", "/docs", "", nil)
require.Equal(t, 301, rr.Code)
@@ -2478,18 +2478,25 @@ func TestServer_PublishWithUTF8MimeHeader(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic", "some attachment", map[string]string{
"X-Filename": "some attachment.txt",
"X-Filename": "some =?UTF-8?q?=C3=A4?=ttachment.txt",
"X-Message": "=?UTF-8?B?8J+HqfCfh6o=?=",
"X-Title": "=?UTF-8?B?bnRmeSDlvojmo5I=?=, no really I mean it! =?UTF-8?Q?This is q=C3=BC=C3=B6ted-print=C3=A4ble.?=",
"X-Tags": "=?UTF-8?B?8J+HqfCfh6o=?=, =?UTF-8?B?bnRmeSDlvojmo5I=?=",
"X-Click": "=?uTf-8?b?aHR0cHM6Ly/wn5KpLmxh?=",
"X-Actions": "http, \"=?utf-8?q?Mettre =C3=A0 jour?=\", \"https://my.tld/webhook/netbird-update\"; =?utf-8?b?aHR0cCwg6L+Z5piv5LiA5Liq5qCH562+LCBodHRwczovL/CfkqkubGE=?=",
})
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "🇩🇪", m.Message)
require.Equal(t, "ntfy 很棒, no really I mean it! This is qüöted-printäble.", m.Title)
require.Equal(t, "some attachment.txt", m.Attachment.Name)
require.Equal(t, "some ättachment.txt", m.Attachment.Name)
require.Equal(t, "🇩🇪", m.Tags[0])
require.Equal(t, "ntfy 很棒", m.Tags[1])
require.Equal(t, "https://💩.la", m.Click)
require.Equal(t, "Mettre à jour", m.Actions[0].Label)
require.Equal(t, "http", m.Actions[1].Action)
require.Equal(t, "这是一个标签", m.Actions[1].Label)
require.Equal(t, "https://💩.la", m.Actions[1].URL)
}
func TestServer_UpstreamBaseURL_Success(t *testing.T) {

View File

@@ -101,6 +101,7 @@ type publishMessage struct {
Attach string `json:"attach"`
Filename string `json:"filename"`
Email string `json:"email"`
Call string `json:"call"`
Delay string `json:"delay"`
}

View File

@@ -50,7 +50,7 @@ func readParam(r *http.Request, names ...string) string {
func readHeaderParam(r *http.Request, names ...string) string {
for _, name := range names {
value := r.Header.Get(name)
value := maybeDecodeHeader(r.Header.Get(name))
if value != "" {
return strings.TrimSpace(value)
}

1
web/.eslintignore Normal file
View File

@@ -0,0 +1 @@
src/app/emojis.js

37
web/.eslintrc Normal file
View File

@@ -0,0 +1,37 @@
{
"extends": ["airbnb", "prettier"],
"env": {
"browser": true
},
"globals": {
"config": "readonly"
},
"parserOptions": {
"ecmaVersion": 2023
},
"rules": {
"no-console": "off",
"class-methods-use-this": "off",
"func-style": ["error", "expression"],
"no-restricted-syntax": ["error", "ForInStatement", "LabeledStatement", "WithStatement"],
"no-await-in-loop": "error",
"import/no-cycle": "warn",
"react/prop-types": "off",
"react/destructuring-assignment": "off",
"react/jsx-no-useless-fragment": "off",
"react/jsx-props-no-spreading": "off",
"react/jsx-no-duplicate-props": [
"error",
{
"ignoreCase": false // For <TextField>'s [iI]nputProps
}
],
"react/function-component-definition": [
"error",
{
"namedComponents": "arrow-function",
"unnamedComponents": "arrow-function"
}
]
}
}

4
web/.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
build/
dist/
public/static/langs/
src/app/emojis.js

49
web/index.html Normal file
View File

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

14464
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,14 +3,16 @@
"version": "1.0.0",
"private": true,
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"start": "NODE_OPTIONS=\"--enable-source-maps\" vite",
"build": "vite build",
"serve": "vite preview",
"format": "prettier . --write",
"format:check": "prettier . --check",
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/"
},
"dependencies": {
"@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.4.2",
"@mui/material": "latest",
"dexie": "^3.2.1",
@@ -25,10 +27,21 @@
"react-i18next": "^11.16.2",
"react-infinite-scroll-component": "^6.1.0",
"react-router-dom": "^6.2.2",
"react-scripts": "^5.0.0",
"stacktrace-gps": "^3.0.4",
"stacktrace-js": "^2.0.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"eslint": "^8.41.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^2.8.8",
"vite": "^4.3.8"
},
"browserslist": {
"production": [
">0.2%",
@@ -40,5 +53,8 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"prettier": {
"printWidth": 140
}
}

View File

@@ -6,14 +6,14 @@
// During web development, you may change values here for rapid testing.
var config = {
base_url: window.location.origin, // Change to test against a different server
app_root: "/app",
enable_login: true,
enable_signup: true,
enable_payments: false,
enable_reservations: true,
enable_emails: true,
enable_calls: true,
billing_contact: "",
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
base_url: window.location.origin, // Change to test against a different server
app_root: "/app",
enable_login: true,
enable_signup: true,
enable_payments: false,
enable_reservations: true,
enable_emails: true,
enable_calls: true,
billing_contact: "",
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
};

View File

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

View File

@@ -1,10 +1,11 @@
/* web app styling overrides */
a, a:visited {
color: #338574;
a,
a:visited {
color: #338574;
}
a:hover {
text-decoration: none;
color: #317f6f;
text-decoration: none;
color: #317f6f;
}

View File

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

View File

@@ -352,5 +352,24 @@
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "économisez jusqu'à {{discount}}%",
"account_upgrade_dialog_tier_price_per_month": "mois",
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} prélevé annuellement. Économisez {{save}}.",
"account_upgrade_dialog_billing_contact_email": "Pour des questions concernant la facturation, merci de nous <Link>contacter</Link> directement."
"account_upgrade_dialog_billing_contact_email": "Pour des questions concernant la facturation, merci de nous <Link>contacter</Link> directement.",
"publish_dialog_call_label": "Appel téléphonique",
"account_basics_phone_numbers_title": "Numéros de téléphone",
"account_basics_phone_numbers_dialog_description": "Pour utiliser la fonctionnalité de notification par appels, vous devez ajouter et vérifier au moins un numéro de téléphone. La vérification peut se faire par SMS ou appel téléphonique.",
"account_basics_phone_numbers_description": "Pour des notifications par appel téléphoniques",
"account_basics_phone_numbers_no_phone_numbers_yet": "Pas encore de numéros de téléphone",
"account_basics_phone_numbers_copied_to_clipboard": "Numéro de téléphone copié dans le presse-papier",
"account_basics_phone_numbers_dialog_title": "Ajouter un numéro de téléphone",
"account_basics_phone_numbers_dialog_number_label": "Numéro de téléphone",
"account_basics_phone_numbers_dialog_number_placeholder": "Ex : +33701020304",
"account_basics_phone_numbers_dialog_verify_button_sms": "Envoyer un SMS",
"account_basics_phone_numbers_dialog_verify_button_call": "Appelez moi",
"account_basics_phone_numbers_dialog_code_label": "Code de vérification",
"account_basics_phone_numbers_dialog_code_placeholder": "Ex : 123456",
"account_basics_phone_numbers_dialog_check_verification_button": "Code de confirmarion",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "Appel",
"account_usage_calls_none": "Aucun appels téléphoniques ne peut être fait avec ce compte",
"publish_dialog_call_reset": "Supprimer les appels téléphoniques",
"publish_dialog_chip_call_label": "Appel téléphonique"
}

View File

@@ -355,5 +355,29 @@
"account_upgrade_dialog_billing_contact_website": "Untuk pertanyaan penagihan, silakan menuju ke <Link>situs web</Link> kami.",
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} topik yang direservasi",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} surel harian",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} pesan harian"
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} pesan harian",
"publish_dialog_call_label": "Panggilan telepon",
"publish_dialog_call_placeholder": "Nomor telepon untuk dipanggil dengan pesan, mis. +622223334444, atau 'yes'",
"account_basics_phone_numbers_title": "Nomor telepon",
"account_basics_phone_numbers_dialog_description": "Untuk menggunakan fitur notifikasi telepon, Anda perlu menambahkan dan memverifikasi setidaknya satu nomor telepon. Verifikasi dapat dilakukan melalui SMS atau panggilan telepon.",
"account_basics_phone_numbers_no_phone_numbers_yet": "Belum ada nomor telepon",
"account_basics_phone_numbers_dialog_title": "Tambahkan nomor telepon",
"account_basics_phone_numbers_dialog_number_label": "Nomor telepon",
"account_basics_phone_numbers_dialog_number_placeholder": "mis. +62222333444",
"account_basics_phone_numbers_dialog_verify_button_sms": "Kirim SMS",
"account_basics_phone_numbers_dialog_channel_call": "Panggil",
"account_usage_calls_title": "Panggilan telepon dilakukan",
"account_usage_calls_none": "Tidak ada panggilan telepon yang dapat dilakukan dengan akun ini",
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} panggilan telepon harian",
"publish_dialog_call_reset": "Hapus panggilan telepon",
"account_basics_phone_numbers_description": "Untuk notifikasi panggilan telepon",
"account_basics_phone_numbers_copied_to_clipboard": "Nomor telepon disalin ke papan klip",
"publish_dialog_chip_call_label": "Panggilan telepon",
"account_basics_phone_numbers_dialog_verify_button_call": "Panggil saya",
"account_basics_phone_numbers_dialog_code_placeholder": "mis. 123456",
"account_basics_phone_numbers_dialog_check_verification_button": "Konfirmasi kode",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} panggilan telepon harian",
"account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon",
"account_basics_phone_numbers_dialog_code_label": "Kode verifikasi"
}

View File

@@ -355,5 +355,6 @@
"account_upgrade_dialog_billing_contact_website": "支払いに関する質問は、<Link>ウェブサイト</Link>を参照して下さい。",
"account_upgrade_dialog_tier_features_messages_one": "毎日 {{messages}} メッセージ",
"account_upgrade_dialog_tier_features_reservations_one": "予約済みトピック {{reservations}} 件",
"account_upgrade_dialog_tier_features_emails_one": "毎日メール {{emails}} 件"
"account_upgrade_dialog_tier_features_emails_one": "毎日メール {{emails}} 件",
"publish_dialog_call_label": "電話"
}

View File

@@ -237,5 +237,120 @@
"display_name_dialog_description": "Задайте альтернативну назву для теми, яка відображатиметься у списку підписок. Це допоможе легше ідентифікувати теми зі складними назвами.",
"display_name_dialog_placeholder": "Відображуване ім'я",
"account_basics_password_title": "Пароль",
"account_basics_username_admin_tooltip": "Ви адміністратор"
"account_basics_username_admin_tooltip": "Ви адміністратор",
"account_basics_tier_interval_monthly": "щомісяця",
"common_copy_to_clipboard": "Скопіювати в буфер обміну",
"account_basics_phone_numbers_title": "Номери телефонів",
"account_basics_phone_numbers_description": "Для сповіщень через телефонні дзвінки",
"account_basics_phone_numbers_no_phone_numbers_yet": "Поки що немає номерів телефонів",
"account_basics_phone_numbers_copied_to_clipboard": "Номер телефону скопійовано в буфер обміну",
"account_basics_phone_numbers_dialog_title": "Додати номер телефону",
"account_basics_phone_numbers_dialog_number_label": "Номер телефону",
"account_basics_phone_numbers_dialog_number_placeholder": "наприклад, +1222333444",
"account_basics_phone_numbers_dialog_verify_button_sms": "Надіслати SMS",
"account_basics_phone_numbers_dialog_verify_button_call": "Зателефонуйте мені",
"account_basics_phone_numbers_dialog_code_label": "Код підтвердження",
"account_basics_phone_numbers_dialog_code_placeholder": "наприклад, 123456",
"account_basics_phone_numbers_dialog_check_verification_button": "Підтвердити код",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "Дзвінок",
"account_basics_tier_interval_yearly": "щороку",
"account_usage_calls_title": "Здійснені телефонні дзвінки",
"account_usage_calls_none": "З цього облікового запису не можна здійснювати телефонні дзвінки",
"account_usage_attachment_storage_title": "Зберігання вкладень",
"account_usage_attachment_storage_description": "{{filesize}} на файл, видаляється після {{expiry}}",
"account_usage_basis_ip_description": "Статистика використання та ліміти для цього облікового запису базуються на вашій IP-адресі, тому вони можуть бути доступні іншим користувачам. Ліміти, показані вище, є приблизними і базуються на існуючих лімітах тарифів.",
"account_usage_cannot_create_portal_session": "Не вдається відкрити білінговий портал",
"account_delete_title": "Видалення облікового запису",
"account_delete_description": "Назавжди видалити свій обліковий запис",
"account_delete_dialog_label": "Пароль",
"account_delete_dialog_button_cancel": "Скасувати",
"account_delete_dialog_button_submit": "Видалити обліковий запис назавжди",
"account_delete_dialog_billing_warning": "Видалення облікового запису також негайно скасовує вашу підписку. Ви більше не матимете доступу до білінгової панелі.",
"account_upgrade_dialog_title": "Зміна рівня облікового запису",
"account_upgrade_dialog_interval_monthly": "Щомісяця",
"account_upgrade_dialog_interval_yearly": "Щорічно",
"account_upgrade_dialog_interval_yearly_discount_save": "економія {{discount}}%",
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "економія до {{discount}}%",
"publish_dialog_call_label": "Телефонний дзвінок",
"publish_dialog_call_placeholder": "Номер телефону, на який потрібно зателефонувати з повідомленням, наприклад, +12223334444 або \"yes\"",
"publish_dialog_chip_call_label": "Телефонний дзвінок",
"publish_dialog_call_reset": "Видалити телефонний дзвінок",
"account_basics_phone_numbers_dialog_description": "Щоб користуватися функцією сповіщення про дзвінки, потрібно додати та верифікувати принаймні один телефонний номер. Верифікацію можна здійснити за допомогою SMS або телефонного дзвінка.",
"account_delete_dialog_description": "Це призведе до остаточного видалення вашого облікового запису, включаючи всі дані, які зберігаються на сервері. Після видалення ваше ім'я користувача буде недоступне протягом 7 днів. Якщо ви дійсно хочете продовжити, будь ласка, підтвердьте свій пароль у полі нижче.",
"account_basics_tier_upgrade_button": "Оновлення до Pro",
"account_basics_password_description": "Зміна пароля облікового запису",
"account_usage_of_limit": "з {{limit}}",
"account_usage_unlimited": "Без обмежень",
"account_basics_tier_description": "Рівень потужності вашого облікового запису",
"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_change_button": "Змінити",
"account_basics_tier_paid_until": "Підписка оплачена до {{date}} і буде автоматично поновлюватися",
"account_basics_tier_payment_overdue": "Ваш платіж прострочено. Будь ласка, оновіть спосіб оплати, інакше ваш обліковий запис буде знижено до нижчого рівня.",
"account_basics_tier_canceled_subscription": "Вашу підписку було скасовано, і з {{date}} вона буде знижена до безкоштовного акаунта.",
"account_basics_tier_manage_billing_button": "Керувати рахунками",
"account_usage_messages_title": "Опубліковані повідомлення",
"account_usage_emails_title": "Надіслані електронні листи",
"account_usage_reservations_title": "Зарезервовані теми",
"account_usage_reservations_none": "Для цього облікового запису немає зарезервованих тем",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} на файл",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} загальне сховище",
"account_upgrade_dialog_tier_current_label": "Поточний",
"account_upgrade_dialog_tier_selected_label": "Вибране",
"account_upgrade_dialog_cancel_warning": "Це <strong> скасує вашу підписку</strong> і знизить версію вашого облікового запису {{date}}. У цю дату резервування тем, а також повідомлення, кешовані на сервері <strong>, буде видалено</strong>.",
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} зарезервовані теми",
"account_upgrade_dialog_tier_features_no_reservations": "Немає зарезервованих тем",
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} повідомлень в день",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} електронний лист в день",
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} електронних листів в день",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} телефонний дзвінок в день",
"account_upgrade_dialog_tier_features_calls_other": "{{дзвінки}} телефонних дзвінків в день",
"account_upgrade_dialog_tier_features_no_calls": "Без телефонних дзвінків",
"account_upgrade_dialog_tier_price_per_month": "місяць",
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} на рік. Рахунок виставляється щомісяця.",
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} виставляється щорічно. Збережіть {{save}}.",
"account_upgrade_dialog_billing_contact_email": "Якщо у вас виникли запитання щодо оплати, <Link>зв’яжіться з нами</Link> безпосередньо.",
"account_upgrade_dialog_billing_contact_website": "Якщо у вас виникли запитання щодо оплати, відвідайте наш <Link>веб-сайт</Link>.",
"account_upgrade_dialog_button_cancel_subscription": "Скасувати підписку",
"account_upgrade_dialog_button_update_subscription": "Оновити підписку",
"account_tokens_title": "Токени доступу",
"account_tokens_table_expires_header": "Термін дії закінчується",
"account_tokens_description": "Використовуйте токени доступу при публікації та підписці через ntfy API, щоб не надсилати свої облікові дані. Ознайомтеся з <Link>документацією</Link>, щоб дізнатися більше.",
"account_tokens_table_token_header": "Токен",
"account_tokens_table_never_expires": "Ніколи не закінчується",
"account_tokens_table_label_header": "Мітка",
"account_tokens_table_current_session": "Поточний сеанс браузера",
"account_tokens_table_last_access_header": "Останній доступ",
"account_tokens_table_copied_to_clipboard": "Токен доступу скопійовано",
"account_tokens_table_cannot_delete_or_edit": "Неможливо редагувати або видалити токен поточного сеансу",
"account_tokens_table_create_token_button": "Створити токен доступу",
"account_tokens_table_last_origin_tooltip": "З IP-адреси {{ip}} натисніть для пошуку",
"account_tokens_dialog_title_create": "Створити токен доступу",
"account_tokens_dialog_button_cancel": "Скасувати",
"account_tokens_dialog_title_edit": "Редагувати токен доступу",
"account_tokens_dialog_title_delete": "Видалити токен доступу",
"account_tokens_dialog_label": "Мітка, наприклад, сповіщення Radarr",
"account_tokens_dialog_button_create": "Створити токен",
"account_tokens_dialog_button_update": "Оновити токен",
"account_tokens_dialog_expires_label": "Термін дії токену доступу закінчується через",
"account_tokens_dialog_expires_x_hours": "Термін дії токена закінчується через {{hours}} годин",
"account_tokens_dialog_expires_x_days": "Термін дії токена закінчується через {{days}} днів",
"account_tokens_delete_dialog_description": "Перш ніж видалити токен доступу, переконайтеся, що жодна програма або скрипт не використовує його. <strong>Ця дія не може бути скасована</strong>.",
"prefs_users_description_no_sync": "Користувачі та паролі не синхронізуються з вашим акаунтом.",
"prefs_users_table_cannot_delete_or_edit": "Неможливо видалити або відредагувати користувача, який увійшов у систему",
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} зарезервована тема",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} повідомлення в день",
"account_tokens_dialog_expires_unchanged": "Залишити термін придатності без змін",
"account_tokens_dialog_expires_never": "Термін дії токена ніколи не закінчується",
"account_tokens_delete_dialog_title": "Видалити токен доступу",
"account_tokens_delete_dialog_submit_button": "Видалити токен назавжди",
"account_upgrade_dialog_proration_info": "<strong>Пропорція</strong>: При переході з одного тарифного плану на інший різниця в ціні буде <strong>списана негайно</strong>. При переході на нижчий рівень залишок коштів буде використано для оплати майбутніх розрахункових періодів.",
"account_upgrade_dialog_reservations_warning_one": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, <strong>будь ласка, видаліть принаймні одне резервування</strong>. Ви можете видалити резервування в <Link>Налаштуваннях</Link>.",
"account_upgrade_dialog_reservations_warning_other": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, <strong>будь ласка, видаліть принаймні {{count}} резервувань</strong>. Ви можете видалити резервування в <Link>Налаштуваннях</Link>.",
"account_upgrade_dialog_button_cancel": "Скасувати",
"account_upgrade_dialog_button_redirect_signup": "Зареєструватися зараз",
"account_upgrade_dialog_button_pay_now": "Оплатити зараз і підписатися"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,183 +0,0 @@
import AppBar from "@mui/material/AppBar";
import Navigation from "./Navigation";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu";
import Typography from "@mui/material/Typography";
import * as React from "react";
import {useState} from "react";
import Box from "@mui/material/Box";
import {topicDisplayName} from "../app/utils";
import db from "../app/db";
import {useLocation, useNavigate} from "react-router-dom";
import MenuItem from '@mui/material/MenuItem';
import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsIcon from '@mui/icons-material/Notifications';
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
import routes from "./routes";
import subscriptionManager from "../app/SubscriptionManager";
import logo from "../img/ntfy.svg";
import {useTranslation} from "react-i18next";
import session from "../app/Session";
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import {Logout, Person, Settings} from "@mui/icons-material";
import ListItemIcon from "@mui/material/ListItemIcon";
import accountApi from "../app/AccountApi";
import PopupMenu from "./PopupMenu";
import { SubscriptionPopup } from "./SubscriptionPopup";
const ActionBar = (props) => {
const { t } = useTranslation();
const location = useLocation();
let title = "ntfy";
if (props.selected) {
title = topicDisplayName(props.selected);
} else if (location.pathname === routes.settings) {
title = t("action_bar_settings");
} else if (location.pathname === routes.account) {
title = t("action_bar_account");
}
return (
<AppBar position="fixed" sx={{
width: '100%',
zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
ml: { sm: `${Navigation.width}px` }
}}>
<Toolbar sx={{
pr: '24px',
background: "linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%)"
}}>
<IconButton
color="inherit"
edge="start"
aria-label={t("action_bar_show_menu")}
onClick={props.onMobileDrawerToggle}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Box
component="img"
src={logo}
alt={t("action_bar_logo_alt")}
sx={{
display: { xs: 'none', sm: 'block' },
marginRight: '10px',
height: '28px'
}}
/>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{title}
</Typography>
{props.selected &&
<SettingsIcons
subscription={props.selected}
onUnsubscribe={props.onUnsubscribe}
/>}
<ProfileIcon/>
</Toolbar>
</AppBar>
);
};
const SettingsIcons = (props) => {
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null);
const subscription = props.subscription;
const handleToggleMute = async () => {
const mutedUntil = (subscription.mutedUntil) ? 0 : 1; // Make this a timestamp in the future
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
}
return (
<>
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} aria-label={t("action_bar_toggle_mute")}>
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
</IconButton>
<IconButton color="inherit" size="large" edge="end" onClick={(ev) => setAnchorEl(ev.currentTarget)} aria-label={t("action_bar_toggle_action_menu")}>
<MoreVertIcon/>
</IconButton>
<SubscriptionPopup
subscription={subscription}
anchor={anchorEl}
placement="right"
onClose={() => setAnchorEl(null)}
/>
</>
);
};
const ProfileIcon = () => {
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const navigate = useNavigate();
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleLogout = async () => {
try {
await accountApi.logout();
await db.delete();
} finally {
session.resetAndRedirect(routes.app);
}
};
return (
<>
{session.exists() &&
<IconButton color="inherit" size="large" edge="end" onClick={handleClick} aria-label={t("action_bar_profile_title")}>
<AccountCircleIcon/>
</IconButton>
}
{!session.exists() && config.enable_login &&
<Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{m: 1}} aria-label={t("action_bar_sign_in")}>
{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")}>
{t("action_bar_sign_up")}
</Button>
}
<PopupMenu
horizontal="right"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
>
<MenuItem onClick={() => navigate(routes.account)}>
<ListItemIcon>
<Person />
</ListItemIcon>
<b>{session.username()}</b>
</MenuItem>
<Divider />
<MenuItem onClick={() => navigate(routes.settings)}>
<ListItemIcon>
<Settings fontSize="small" />
</ListItemIcon>
{t("action_bar_profile_settings")}
</MenuItem>
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<Logout fontSize="small" />
</ListItemIcon>
{t("action_bar_profile_logout")}
</MenuItem>
</PopupMenu>
</>
);
};
export default ActionBar;

View File

@@ -0,0 +1,172 @@
import { AppBar, Toolbar, IconButton, Typography, Box, MenuItem, Button, Divider, ListItemIcon } from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import * as React from "react";
import { useState } from "react";
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 { useTranslation } from "react-i18next";
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import { Logout, Person, Settings } from "@mui/icons-material";
import session from "../app/Session";
import logo from "../img/ntfy.svg";
import subscriptionManager from "../app/SubscriptionManager";
import routes from "./routes";
import db from "../app/db";
import { topicDisplayName } from "../app/utils";
import Navigation from "./Navigation";
import accountApi from "../app/AccountApi";
import PopupMenu from "./PopupMenu";
import { SubscriptionPopup } from "./SubscriptionPopup";
const ActionBar = (props) => {
const { t } = useTranslation();
const location = useLocation();
let title = "ntfy";
if (props.selected) {
title = topicDisplayName(props.selected);
} else if (location.pathname === routes.settings) {
title = t("action_bar_settings");
} else if (location.pathname === routes.account) {
title = t("action_bar_account");
}
return (
<AppBar
position="fixed"
sx={{
width: "100%",
zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
ml: { sm: `${Navigation.width}px` },
}}
>
<Toolbar
sx={{
pr: "24px",
background: "linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%)",
}}
>
<IconButton
color="inherit"
edge="start"
aria-label={t("action_bar_show_menu")}
onClick={props.onMobileDrawerToggle}
sx={{ mr: 2, display: { sm: "none" } }}
>
<MenuIcon />
</IconButton>
<Box
component="img"
src={logo}
alt={t("action_bar_logo_alt")}
sx={{
display: { xs: "none", sm: "block" },
marginRight: "10px",
height: "28px",
}}
/>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{title}
</Typography>
{props.selected && <SettingsIcons subscription={props.selected} onUnsubscribe={props.onUnsubscribe} />}
<ProfileIcon />
</Toolbar>
</AppBar>
);
};
const SettingsIcons = (props) => {
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null);
const { subscription } = props;
const handleToggleMute = async () => {
const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
};
return (
<>
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} aria-label={t("action_bar_toggle_mute")}>
{subscription.mutedUntil ? <NotificationsOffIcon /> : <NotificationsIcon />}
</IconButton>
<IconButton
color="inherit"
size="large"
edge="end"
onClick={(ev) => setAnchorEl(ev.currentTarget)}
aria-label={t("action_bar_toggle_action_menu")}
>
<MoreVertIcon />
</IconButton>
<SubscriptionPopup subscription={subscription} anchor={anchorEl} placement="right" onClose={() => setAnchorEl(null)} />
</>
);
};
const ProfileIcon = () => {
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const navigate = useNavigate();
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleLogout = async () => {
try {
await accountApi.logout();
await db.delete();
} finally {
session.resetAndRedirect(routes.app);
}
};
return (
<>
{session.exists() && (
<IconButton color="inherit" size="large" edge="end" onClick={handleClick} aria-label={t("action_bar_profile_title")}>
<AccountCircleIcon />
</IconButton>
)}
{!session.exists() && config.enable_login && (
<Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{ m: 1 }} aria-label={t("action_bar_sign_in")}>
{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")}>
{t("action_bar_sign_up")}
</Button>
)}
<PopupMenu horizontal="right" anchorEl={anchorEl} open={open} onClose={handleClose}>
<MenuItem onClick={() => navigate(routes.account)}>
<ListItemIcon>
<Person />
</ListItemIcon>
<b>{session.username()}</b>
</MenuItem>
<Divider />
<MenuItem onClick={() => navigate(routes.settings)}>
<ListItemIcon>
<Settings fontSize="small" />
</ListItemIcon>
{t("action_bar_profile_settings")}
</MenuItem>
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<Logout fontSize="small" />
</ListItemIcon>
{t("action_bar_profile_logout")}
</MenuItem>
</PopupMenu>
</>
);
};
export default ActionBar;

View File

@@ -1,147 +0,0 @@
import * as React from 'react';
import {createContext, Suspense, useContext, useEffect, useState} from 'react';
import Box from '@mui/material/Box';
import {ThemeProvider} from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import Toolbar from '@mui/material/Toolbar';
import {AllSubscriptions, SingleSubscription} from "./Notifications";
import theme from "./theme";
import Navigation from "./Navigation";
import ActionBar from "./ActionBar";
import notifier from "../app/Notifier";
import Preferences from "./Preferences";
import {useLiveQuery} from "dexie-react-hooks";
import subscriptionManager from "../app/SubscriptionManager";
import userManager from "../app/UserManager";
import {BrowserRouter, Outlet, Route, Routes, useParams} from "react-router-dom";
import {expandUrl} from "../app/utils";
import ErrorBoundary from "./ErrorBoundary";
import routes from "./routes";
import {useAccountListener, useBackgroundProcesses, useConnectionListeners} from "./hooks";
import PublishDialog from "./PublishDialog";
import Messaging from "./Messaging";
import "./i18n"; // Translations!
import {Backdrop, CircularProgress} from "@mui/material";
import Login from "./Login";
import Signup from "./Signup";
import Account from "./Account";
export const AccountContext = createContext(null);
const App = () => {
const [account, setAccount] = useState(null);
return (
<Suspense fallback={<Loader />}>
<BrowserRouter>
<ThemeProvider theme={theme}>
<AccountContext.Provider value={{ account, setAccount }}>
<CssBaseline/>
<ErrorBoundary>
<Routes>
<Route path={routes.login} element={<Login/>}/>
<Route path={routes.signup} element={<Signup/>}/>
<Route element={<Layout/>}>
<Route path={routes.app} element={<AllSubscriptions/>}/>
<Route path={routes.account} element={<Account/>}/>
<Route path={routes.settings} element={<Preferences/>}/>
<Route path={routes.subscription} element={<SingleSubscription/>}/>
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
</Route>
</Routes>
</ErrorBoundary>
</AccountContext.Provider>
</ThemeProvider>
</BrowserRouter>
</Suspense>
);
}
const Layout = () => {
const params = useParams();
const { account, setAccount } = useContext(AccountContext);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all());
const subscriptionsWithoutInternal = subscriptions?.filter(s => !s.internal);
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
const [selected] = (subscriptionsWithoutInternal || []).filter(s => {
return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic)
|| (config.base_url === s.baseUrl && params.topic === s.topic)
});
useConnectionListeners(account, subscriptions, users);
useAccountListener(setAccount)
useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
return (
<Box sx={{display: 'flex'}}>
<ActionBar
selected={selected}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
/>
<Navigation
subscriptions={subscriptionsWithoutInternal}
selectedSubscription={selected}
notificationsGranted={notificationsGranted}
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onNotificationGranted={setNotificationsGranted}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
/>
<Main>
<Toolbar/>
<Outlet context={{
subscriptions: subscriptionsWithoutInternal,
selected: selected
}}/>
</Main>
<Messaging
selected={selected}
dialogOpenMode={sendDialogOpenMode}
onDialogOpenModeChange={setSendDialogOpenMode}
/>
</Box>
);
}
const Main = (props) => {
return (
<Box
id="main"
component="main"
sx={{
display: 'flex',
flexGrow: 1,
flexDirection: 'column',
padding: 3,
width: {sm: `calc(100% - ${Navigation.width}px)`},
height: '100vh',
overflow: 'auto',
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
}}
>
{props.children}
</Box>
);
};
const Loader = () => (
<Backdrop
open={true}
sx={{
zIndex: 100000,
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
}}
>
<CircularProgress color="success" disableShrink />
</Backdrop>
);
const updateTitle = (newNotificationsCount) => {
document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy";
}
export default App;

140
web/src/components/App.jsx Normal file
View File

@@ -0,0 +1,140 @@
import * as React from "react";
import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react";
import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress } from "@mui/material";
import { ThemeProvider } from "@mui/material/styles";
import { useLiveQuery } from "dexie-react-hooks";
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
import { AllSubscriptions, SingleSubscription } from "./Notifications";
import theme from "./theme";
import Navigation from "./Navigation";
import ActionBar from "./ActionBar";
import notifier from "../app/Notifier";
import Preferences from "./Preferences";
import subscriptionManager from "../app/SubscriptionManager";
import userManager from "../app/UserManager";
import { expandUrl } from "../app/utils";
import ErrorBoundary from "./ErrorBoundary";
import routes from "./routes";
import { useAccountListener, useBackgroundProcesses, useConnectionListeners } from "./hooks";
import PublishDialog from "./PublishDialog";
import Messaging from "./Messaging";
import "./i18n"; // Translations!
import Login from "./Login";
import Signup from "./Signup";
import Account from "./Account";
export const AccountContext = createContext(null);
const App = () => {
const [account, setAccount] = useState(null);
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
return (
<Suspense fallback={<Loader />}>
<BrowserRouter>
<ThemeProvider theme={theme}>
<AccountContext.Provider value={accountMemo}>
<CssBaseline />
<ErrorBoundary>
<Routes>
<Route path={routes.login} element={<Login />} />
<Route path={routes.signup} element={<Signup />} />
<Route element={<Layout />}>
<Route path={routes.app} element={<AllSubscriptions />} />
<Route path={routes.account} element={<Account />} />
<Route path={routes.settings} element={<Preferences />} />
<Route path={routes.subscription} element={<SingleSubscription />} />
<Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
</Route>
</Routes>
</ErrorBoundary>
</AccountContext.Provider>
</ThemeProvider>
</BrowserRouter>
</Suspense>
);
};
const updateTitle = (newNotificationsCount) => {
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
};
const Layout = () => {
const params = useParams();
const { account, setAccount } = useContext(AccountContext);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all());
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
const [selected] = (subscriptionsWithoutInternal || []).filter(
(s) =>
(params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) ||
(config.base_url === s.baseUrl && params.topic === s.topic)
);
useConnectionListeners(account, subscriptions, users);
useAccountListener(setAccount);
useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
return (
<Box sx={{ display: "flex" }}>
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
<Navigation
subscriptions={subscriptionsWithoutInternal}
selectedSubscription={selected}
notificationsGranted={notificationsGranted}
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onNotificationGranted={setNotificationsGranted}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
/>
<Main>
<Toolbar />
<Outlet
context={{
subscriptions: subscriptionsWithoutInternal,
selected,
}}
/>
</Main>
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
</Box>
);
};
const Main = (props) => (
<Box
id="main"
component="main"
sx={{
display: "flex",
flexGrow: 1,
flexDirection: "column",
padding: 3,
width: { sm: `calc(100% - ${Navigation.width}px)` },
height: "100vh",
overflow: "auto",
backgroundColor: ({ palette }) => (palette.mode === "light" ? palette.grey[100] : palette.grey[900]),
}}
>
{props.children}
</Box>
);
const Loader = () => (
<Backdrop
open
sx={{
zIndex: 100000,
backgroundColor: ({ palette }) => (palette.mode === "light" ? palette.grey[100] : palette.grey[900]),
}}
>
<CircularProgress color="success" disableShrink />
</Backdrop>
);
export default App;

View File

@@ -1,47 +0,0 @@
import * as React from "react";
import Box from "@mui/material/Box";
import fileDocument from "../img/file-document.svg";
import fileImage from "../img/file-image.svg";
import fileVideo from "../img/file-video.svg";
import fileAudio from "../img/file-audio.svg";
import fileApp from "../img/file-app.svg";
import {useTranslation} from "react-i18next";
const AttachmentIcon = (props) => {
const { t } = useTranslation();
const type = props.type;
let imageFile, imageLabel;
if (!type) {
imageFile = fileDocument;
imageLabel = t("notifications_attachment_file_image");
} else if (type.startsWith('image/')) {
imageFile = fileImage;
imageLabel = t("notifications_attachment_file_video");
} else if (type.startsWith('video/')) {
imageFile = fileVideo;
imageLabel = t("notifications_attachment_file_video");
} else if (type.startsWith('audio/')) {
imageFile = fileAudio;
imageLabel = t("notifications_attachment_file_audio");
} else if (type === "application/vnd.android.package-archive") {
imageFile = fileApp;
imageLabel = t("notifications_attachment_file_app");
} else {
imageFile = fileDocument;
imageLabel = t("notifications_attachment_file_document");
}
return (
<Box
component="img"
src={imageFile}
alt={imageLabel}
loading="lazy"
sx={{
width: '28px',
height: '28px'
}}
/>
);
}
export default AttachmentIcon;

View File

@@ -0,0 +1,48 @@
import * as React from "react";
import { Box } from "@mui/material";
import { useTranslation } from "react-i18next";
import fileDocument from "../img/file-document.svg";
import fileImage from "../img/file-image.svg";
import fileVideo from "../img/file-video.svg";
import fileAudio from "../img/file-audio.svg";
import fileApp from "../img/file-app.svg";
const AttachmentIcon = (props) => {
const { t } = useTranslation();
const { type } = props;
let imageFile;
let imageLabel;
if (!type) {
imageFile = fileDocument;
imageLabel = t("notifications_attachment_file_image");
} else if (type.startsWith("image/")) {
imageFile = fileImage;
imageLabel = t("notifications_attachment_file_video");
} else if (type.startsWith("video/")) {
imageFile = fileVideo;
imageLabel = t("notifications_attachment_file_video");
} else if (type.startsWith("audio/")) {
imageFile = fileAudio;
imageLabel = t("notifications_attachment_file_audio");
} else if (type === "application/vnd.android.package-archive") {
imageFile = fileApp;
imageLabel = t("notifications_attachment_file_app");
} else {
imageFile = fileDocument;
imageLabel = t("notifications_attachment_file_document");
}
return (
<Box
component="img"
src={imageFile}
alt={imageLabel}
loading="lazy"
sx={{
width: "28px",
height: "28px",
}}
/>
);
};
export default AttachmentIcon;

View File

@@ -1,29 +0,0 @@
import * as React from 'react';
import {Avatar} from "@mui/material";
import Box from "@mui/material/Box";
import logo from "../img/ntfy-filled.svg";
const AvatarBox = (props) => {
return (
<Box
sx={{
display: 'flex',
flexGrow: 1,
justifyContent: 'center',
flexDirection: 'column',
alignContent: 'center',
alignItems: 'center',
height: '100vh'
}}
>
<Avatar
sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }}
src={logo}
variant="rounded"
/>
{props.children}
</Box>
);
}
export default AvatarBox;

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { Avatar, Box } from "@mui/material";
import logo from "../img/ntfy-filled.svg";
const AvatarBox = (props) => (
<Box
sx={{
display: "flex",
flexGrow: 1,
justifyContent: "center",
flexDirection: "column",
alignContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />
{props.children}
</Box>
);
export default AvatarBox;

View File

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

View File

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

View File

@@ -1,179 +0,0 @@
import * as React from 'react';
import {useRef, useState} from 'react';
import Typography from '@mui/material/Typography';
import {rawEmojis} from '../app/emojis';
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField";
import {ClickAwayListener, Fade, InputAdornment, styled} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import {Close} from "@mui/icons-material";
import Popper from "@mui/material/Popper";
import {splitNoEmpty} from "../app/utils";
import {useTranslation} from "react-i18next";
// Create emoji list by category and create a search base (string with all search words)
//
// This also filters emojis that are not supported by Desktop Chrome.
// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported.
const emojisByCategory = {};
const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);
const maxSupportedVersionForDesktopChrome = 11;
rawEmojis.forEach(emoji => {
if (!emojisByCategory[emoji.category]) {
emojisByCategory[emoji.category] = [];
}
try {
const unicodeVersion = parseFloat(emoji.unicode_version);
const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
if (supportedEmoji) {
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`;
const emojiWithSearchBase = { ...emoji, searchBase: searchBase };
emojisByCategory[emoji.category].push(emojiWithSearchBase);
}
} catch (e) {
// Nothing. Ignore.
}
});
const EmojiPicker = (props) => {
const { t } = useTranslation();
const open = Boolean(props.anchorEl);
const [search, setSearch] = useState("");
const searchRef = useRef(null);
const searchFields = splitNoEmpty(search.toLowerCase(), " ");
const handleSearchClear = () => {
setSearch("");
searchRef.current?.focus();
};
return (
<Popper
open={open}
anchorEl={props.anchorEl}
placement="bottom-start"
sx={{ zIndex: 10005 }}
transition
>
{({ TransitionProps }) => (
<ClickAwayListener onClickAway={props.onClose}>
<Fade {...TransitionProps} timeout={350}>
<Box sx={{
boxShadow: 3,
padding: 2,
paddingRight: 0,
paddingBottom: 1,
width: "380px",
maxHeight: "300px",
backgroundColor: 'background.paper',
overflowY: "scroll"
}}>
<TextField
inputRef={searchRef}
margin="dense"
size="small"
placeholder={t("emoji_picker_search_placeholder")}
value={search}
onChange={ev => setSearch(ev.target.value)}
type="text"
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>
}}
/>
<Box sx={{ display: "flex", flexWrap: "wrap", paddingRight: 0, marginTop: 1 }}>
{Object.keys(emojisByCategory).map(category =>
<Category
key={category}
title={category}
emojis={emojisByCategory[category]}
search={searchFields}
onPick={props.onEmojiPick}
/>
)}
</Box>
</Box>
</Fade>
</ClickAwayListener>
)}
</Popper>
);
};
const Category = (props) => {
const showTitle = props.search.length === 0;
return (
<>
{showTitle &&
<Typography variant="body1" sx={{ width: "100%", marginBottom: 1 }}>
{props.title}
</Typography>
}
{props.emojis.map(emoji =>
<Emoji
key={emoji.aliases[0]}
emoji={emoji}
search={props.search}
onClick={() => props.onPick(emoji.aliases[0])}
/>
)}
</>
);
};
const Emoji = (props) => {
const emoji = props.emoji;
const matches = emojiMatches(emoji, props.search);
const title = `${emoji.description} (${emoji.aliases[0]})`;
return (
<EmojiDiv
onClick={props.onClick}
title={title}
aria-label={title}
style={{ display: (matches) ? '' : 'none' }}
>
{props.emoji.emoji}
</EmojiDiv>
);
};
const EmojiDiv = styled("div")({
fontSize: "30px",
width: "30px",
height: "30px",
marginTop: "8px",
marginBottom: "8px",
marginRight: "8px",
lineHeight: "30px",
cursor: "pointer",
opacity: 0.85,
"&:hover": {
opacity: 1
}
});
const emojiMatches = (emoji, words) => {
if (words.length === 0) {
return true;
}
for (const word of words) {
if (emoji.searchBase.indexOf(word) === -1) {
return false;
}
}
return true;
}
export default EmojiPicker;

View File

@@ -0,0 +1,158 @@
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 { useTranslation } from "react-i18next";
import { splitNoEmpty } from "../app/utils";
import { rawEmojis } from "../app/emojis";
// Create emoji list by category and create a search base (string with all search words)
//
// This also filters emojis that are not supported by Desktop Chrome.
// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported.
const emojisByCategory = {};
const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);
const maxSupportedVersionForDesktopChrome = 11;
rawEmojis.forEach((emoji) => {
if (!emojisByCategory[emoji.category]) {
emojisByCategory[emoji.category] = [];
}
try {
const unicodeVersion = parseFloat(emoji.unicode_version);
const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
if (supportedEmoji) {
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`;
const emojiWithSearchBase = { ...emoji, searchBase };
emojisByCategory[emoji.category].push(emojiWithSearchBase);
}
} catch (e) {
// Nothing. Ignore.
}
});
const EmojiPicker = (props) => {
const { t } = useTranslation();
const open = Boolean(props.anchorEl);
const [search, setSearch] = useState("");
const searchRef = useRef(null);
const searchFields = splitNoEmpty(search.toLowerCase(), " ");
const handleSearchClear = () => {
setSearch("");
searchRef.current?.focus();
};
return (
<Popper open={open} anchorEl={props.anchorEl} placement="bottom-start" sx={{ zIndex: 10005 }} transition>
{({ TransitionProps }) => (
<ClickAwayListener onClickAway={props.onClose}>
<Fade {...TransitionProps} timeout={350}>
<Box
sx={{
boxShadow: 3,
padding: 2,
paddingRight: 0,
paddingBottom: 1,
width: "380px",
maxHeight: "300px",
backgroundColor: "background.paper",
overflowY: "scroll",
}}
>
<TextField
inputRef={searchRef}
margin="dense"
size="small"
placeholder={t("emoji_picker_search_placeholder")}
value={search}
onChange={(ev) => setSearch(ev.target.value)}
type="text"
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>
),
}}
/>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
paddingRight: 0,
marginTop: 1,
}}
>
{Object.keys(emojisByCategory).map((category) => (
<Category
key={category}
title={category}
emojis={emojisByCategory[category]}
search={searchFields}
onPick={props.onEmojiPick}
/>
))}
</Box>
</Box>
</Fade>
</ClickAwayListener>
)}
</Popper>
);
};
const Category = (props) => {
const showTitle = props.search.length === 0;
return (
<>
{showTitle && (
<Typography variant="body1" sx={{ width: "100%", marginBottom: 1 }}>
{props.title}
</Typography>
)}
{props.emojis.map((emoji) => (
<Emoji key={emoji.aliases[0]} emoji={emoji} search={props.search} onClick={() => props.onPick(emoji.aliases[0])} />
))}
</>
);
};
const emojiMatches = (emoji, words) => words.length === 0 || words.some((word) => emoji.searchBase.includes(word));
const Emoji = (props) => {
const { emoji } = props;
const matches = emojiMatches(emoji, props.search);
const title = `${emoji.description} (${emoji.aliases[0]})`;
return (
<EmojiDiv onClick={props.onClick} title={title} aria-label={title} style={{ display: matches ? "" : "none" }}>
{props.emoji.emoji}
</EmojiDiv>
);
};
const EmojiDiv = styled("div")({
fontSize: "30px",
width: "30px",
height: "30px",
marginTop: "8px",
marginBottom: "8px",
marginRight: "8px",
lineHeight: "30px",
cursor: "pointer",
opacity: 0.85,
"&:hover": {
opacity: 1,
},
});
export default EmojiPicker;

View File

@@ -1,129 +0,0 @@
import * as React from "react";
import StackTrace from "stacktrace-js";
import {CircularProgress, Link} from "@mui/material";
import Button from "@mui/material/Button";
import {Trans, withTranslation} from "react-i18next";
class ErrorBoundaryImpl extends React.Component {
constructor(props) {
super(props);
this.state = {
error: false,
originalStack: null,
niceStack: null,
unsupportedIndexedDB: false
};
}
componentDidCatch(error, info) {
console.error("[ErrorBoundary] Error caught", error, info);
// Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see
// - https://github.com/dexie/Dexie.js/issues/312
// - https://bugzilla.mozilla.org/show_bug.cgi?id=781982
const isUnsupportedIndexedDB = error?.name === "InvalidStateError" ||
(error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1);
if (isUnsupportedIndexedDB) {
this.handleUnsupportedIndexedDB();
} else {
this.handleError(error, info);
}
}
handleError(error, info) {
// Immediately render original stack trace
const prettierOriginalStack = info.componentStack
.trim()
.split("\n")
.map(line => ` at ${line}`)
.join("\n");
this.setState({
error: true,
originalStack: `${error.toString()}\n${prettierOriginalStack}`
});
// Fetch additional info and a better stack trace
StackTrace.fromError(error).then(stack => {
console.error("[ErrorBoundary] Stacktrace fetched", stack);
const niceStack = `${error.toString()}\n` + stack.map( el => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
this.setState({ niceStack });
});
}
handleUnsupportedIndexedDB() {
this.setState({
error: true,
unsupportedIndexedDB: true
});
}
copyStack() {
let stack = "";
if (this.state.niceStack) {
stack += `${this.state.niceStack}\n\n`;
}
stack += `${this.state.originalStack}\n`;
navigator.clipboard.writeText(stack);
}
render() {
if (this.state.error) {
if (this.state.unsupportedIndexedDB) {
return this.renderUnsupportedIndexedDB();
} else {
return this.renderError();
}
}
return this.props.children;
}
renderUnsupportedIndexedDB() {
const { t } = this.props;
return (
<div style={{margin: '20px'}}>
<h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2>
<p style={{maxWidth: "600px"}}>
<Trans
i18nKey="error_boundary_unsupported_indexeddb_description"
components={{
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues/208"/>,
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
}}
/>
</p>
</div>
);
}
renderError() {
const { t } = this.props;
return (
<div style={{margin: '20px'}}>
<h2>{t("error_boundary_title")} 😮</h2>
<p>
<Trans
i18nKey="error_boundary_description"
components={{
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues"/>,
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
}}
/>
</p>
<p>
<Button variant="outlined" onClick={() => this.copyStack()}>{t("error_boundary_button_copy_stack_trace")}</Button>
</p>
<h3>{t("error_boundary_stack_trace")}</h3>
{this.state.niceStack
? <pre>{this.state.niceStack}</pre>
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> {t("error_boundary_gathering_info")}</>}
<pre>{this.state.originalStack}</pre>
</div>
);
}
}
const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t
export default ErrorBoundary;

View File

@@ -0,0 +1,134 @@
import * as React from "react";
import StackTrace from "stacktrace-js";
import { CircularProgress, Link, Button } from "@mui/material";
import { Trans, withTranslation } from "react-i18next";
class ErrorBoundaryImpl extends React.Component {
constructor(props) {
super(props);
this.state = {
error: false,
originalStack: null,
niceStack: null,
unsupportedIndexedDB: false,
};
}
componentDidCatch(error, info) {
console.error("[ErrorBoundary] Error caught", error, info);
// Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see
// - https://github.com/dexie/Dexie.js/issues/312
// - https://bugzilla.mozilla.org/show_bug.cgi?id=781982
const isUnsupportedIndexedDB =
error?.name === "InvalidStateError" || (error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1);
if (isUnsupportedIndexedDB) {
this.handleUnsupportedIndexedDB();
} else {
this.handleError(error, info);
}
}
handleError(error, info) {
// Immediately render original stack trace
const prettierOriginalStack = info.componentStack
.trim()
.split("\n")
.map((line) => ` at ${line}`)
.join("\n");
this.setState({
error: true,
originalStack: `${error.toString()}\n${prettierOriginalStack}`,
});
// Fetch additional info and a better stack trace
StackTrace.fromError(error).then((stack) => {
console.error("[ErrorBoundary] Stacktrace fetched", stack);
const stackString = stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
const niceStack = `${error.toString()}\n${stackString}`;
this.setState({ niceStack });
});
}
handleUnsupportedIndexedDB() {
this.setState({
error: true,
unsupportedIndexedDB: true,
});
}
copyStack() {
let stack = "";
if (this.state.niceStack) {
stack += `${this.state.niceStack}\n\n`;
}
stack += `${this.state.originalStack}\n`;
navigator.clipboard.writeText(stack);
}
renderUnsupportedIndexedDB() {
const { t } = this.props;
return (
<div style={{ margin: "20px" }}>
<h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2>
<p style={{ maxWidth: "600px" }}>
<Trans
i18nKey="error_boundary_unsupported_indexeddb_description"
components={{
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues/208" />,
discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />,
}}
/>
</p>
</div>
);
}
renderError() {
const { t } = this.props;
return (
<div style={{ margin: "20px" }}>
<h2>{t("error_boundary_title")} 😮</h2>
<p>
<Trans
i18nKey="error_boundary_description"
components={{
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues" />,
discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />,
}}
/>
</p>
<p>
<Button variant="outlined" onClick={() => this.copyStack()}>
{t("error_boundary_button_copy_stack_trace")}
</Button>
</p>
<h3>{t("error_boundary_stack_trace")}</h3>
{this.state.niceStack ? (
<pre>{this.state.niceStack}</pre>
) : (
<>
<CircularProgress size="20px" sx={{ verticalAlign: "text-bottom" }} /> {t("error_boundary_gathering_info")}
</>
)}
<pre>{this.state.originalStack}</pre>
</div>
);
}
render() {
if (this.state.error) {
if (this.state.unsupportedIndexedDB) {
return this.renderUnsupportedIndexedDB();
}
return this.renderError();
}
return this.props.children;
}
}
const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t
export default ErrorBoundary;

View File

@@ -1,122 +0,0 @@
import * as React from 'react';
import {useState} from 'react';
import Typography from "@mui/material/Typography";
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import routes from "./routes";
import session from "../app/Session";
import {NavLink} from "react-router-dom";
import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next";
import accountApi from "../app/AccountApi";
import IconButton from "@mui/material/IconButton";
import {InputAdornment} from "@mui/material";
import {Visibility, VisibilityOff} from "@mui/icons-material";
import {UnauthorizedError} from "../app/errors";
const Login = () => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault();
const user = { username, password };
try {
const token = await accountApi.login(user);
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
session.store(user.username, token);
window.location.href = routes.app;
} catch (e) {
console.log(`[Login] User auth for user ${user.username} failed`, e);
if (e instanceof UnauthorizedError) {
setError(t("Login failed: Invalid username or password"));
} else {
setError(e.message);
}
}
};
if (!config.enable_login) {
return (
<AvatarBox>
<Typography sx={{ typography: 'h6' }}>{t("login_disabled")}</Typography>
</AvatarBox>
);
}
return (
<AvatarBox>
<Typography sx={{ typography: 'h6' }}>
{t("login_title")}
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}>
<TextField
margin="dense"
required
fullWidth
id="username"
label={t("signup_form_username")}
name="username"
value={username}
onChange={ev => setUsername(ev.target.value.trim())}
autoFocus
/>
<TextField
margin="dense"
required
fullWidth
name="password"
label={t("signup_form_password")}
type={showPassword ? "text" : "password"}
id="password"
value={password}
onChange={ev => setPassword(ev.target.value.trim())}
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>
)
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
disabled={username === "" || password === ""}
sx={{mt: 2, mb: 2}}
>
{t("login_form_button_submit")}
</Button>
{error &&
<Box sx={{
mb: 1,
display: 'flex',
flexGrow: 1,
justifyContent: 'center',
}}>
<WarningAmberIcon color="error" sx={{mr: 1}}/>
<Typography sx={{color: 'error.main'}}>{error}</Typography>
</Box>
}
<Box sx={{width: "100%"}}>
{/* This is where the password reset link would go */}
{config.enable_signup && <div style={{float: "right"}}><NavLink to={routes.signup} variant="body1">{t("login_link_signup")}</NavLink></div>}
</Box>
</Box>
</AvatarBox>
);
}
export default Login;

View File

@@ -0,0 +1,117 @@
import * as React from "react";
import { useState } from "react";
import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material";
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 accountApi from "../app/AccountApi";
import AvatarBox from "./AvatarBox";
import session from "../app/Session";
import routes from "./routes";
import { UnauthorizedError } from "../app/errors";
const Login = () => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault();
const user = { username, password };
try {
const token = await accountApi.login(user);
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
session.store(user.username, token);
window.location.href = routes.app;
} catch (e) {
console.log(`[Login] User auth for user ${user.username} failed`, e);
if (e instanceof UnauthorizedError) {
setError(t("Login failed: Invalid username or password"));
} else {
setError(e.message);
}
}
};
if (!config.enable_login) {
return (
<AvatarBox>
<Typography sx={{ typography: "h6" }}>{t("login_disabled")}</Typography>
</AvatarBox>
);
}
return (
<AvatarBox>
<Typography sx={{ typography: "h6" }}>{t("login_title")}</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1, maxWidth: 400 }}>
<TextField
margin="dense"
required
fullWidth
id="username"
label={t("signup_form_username")}
name="username"
value={username}
onChange={(ev) => setUsername(ev.target.value.trim())}
autoFocus
/>
<TextField
margin="dense"
required
fullWidth
name="password"
label={t("signup_form_password")}
type={showPassword ? "text" : "password"}
id="password"
value={password}
onChange={(ev) => setPassword(ev.target.value.trim())}
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>
),
}}
/>
<Button type="submit" fullWidth variant="contained" disabled={username === "" || password === ""} sx={{ mt: 2, mb: 2 }}>
{t("login_form_button_submit")}
</Button>
{error && (
<Box
sx={{
mb: 1,
display: "flex",
flexGrow: 1,
justifyContent: "center",
}}
>
<WarningAmberIcon color="error" sx={{ mr: 1 }} />
<Typography sx={{ color: "error.main" }}>{error}</Typography>
</Box>
)}
<Box sx={{ width: "100%" }}>
{/* This is where the password reset link would go */}
{config.enable_signup && (
<div style={{ float: "right" }}>
<NavLink to={routes.signup} variant="body1">
{t("login_link_signup")}
</NavLink>
</div>
)}
</Box>
</Box>
</AvatarBox>
);
};
export default Login;

View File

@@ -1,114 +0,0 @@
import * as React from 'react';
import {useState} from 'react';
import Navigation from "./Navigation";
import Paper from "@mui/material/Paper";
import IconButton from "@mui/material/IconButton";
import TextField from "@mui/material/TextField";
import SendIcon from "@mui/icons-material/Send";
import api from "../app/Api";
import PublishDialog from "./PublishDialog";
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import {Portal, Snackbar} from "@mui/material";
import {useTranslation} from "react-i18next";
const Messaging = (props) => {
const [message, setMessage] = useState("");
const [dialogKey, setDialogKey] = useState(0);
const dialogOpenMode = props.dialogOpenMode;
const subscription = props.selected;
const handleOpenDialogClick = () => {
props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT);
};
const handleDialogClose = () => {
props.onDialogOpenModeChange("");
setDialogKey(prev => prev+1);
};
return (
<>
{subscription && <MessageBar
subscription={subscription}
message={message}
onMessageChange={setMessage}
onOpenDialogClick={handleOpenDialogClick}
/>}
<PublishDialog
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
openMode={dialogOpenMode}
baseUrl={subscription?.baseUrl ?? config.base_url}
topic={subscription?.topic ?? ""}
message={message}
onClose={handleDialogClose}
onDragEnter={() => props.onDialogOpenModeChange(prev => (prev) ? prev : PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open
onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}
/>
</>
);
}
const MessageBar = (props) => {
const { t } = useTranslation();
const subscription = props.subscription;
const [snackOpen, setSnackOpen] = useState(false);
const handleSendClick = async () => {
try {
await api.publish(subscription.baseUrl, subscription.topic, props.message);
} catch (e) {
console.log(`[MessageBar] Error publishing message`, e);
setSnackOpen(true);
}
props.onMessageChange("");
};
return (
<Paper
elevation={3}
sx={{
display: "flex",
position: 'fixed',
bottom: 0,
right: 0,
padding: 2,
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
}}
>
<IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick} aria-label={t("message_bar_show_dialog")}>
<KeyboardArrowUpIcon/>
</IconButton>
<TextField
autoFocus
margin="dense"
placeholder={t("message_bar_type_message")}
aria-label={t("message_bar_type_message")}
role="textbox"
type="text"
fullWidth
variant="standard"
value={props.message}
onChange={ev => props.onMessageChange(ev.target.value)}
onKeyPress={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleSendClick();
}
}}
/>
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick} aria-label={t("message_bar_publish")}>
<SendIcon/>
</IconButton>
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("message_bar_error_publishing")}
/>
</Portal>
</Paper>
);
};
export default Messaging;

View File

@@ -0,0 +1,108 @@
import * as React from "react";
import { useState } from "react";
import { Paper, IconButton, TextField, Portal, Snackbar } from "@mui/material";
import SendIcon from "@mui/icons-material/Send";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { useTranslation } from "react-i18next";
import PublishDialog from "./PublishDialog";
import api from "../app/Api";
import Navigation from "./Navigation";
const Messaging = (props) => {
const [message, setMessage] = useState("");
const [dialogKey, setDialogKey] = useState(0);
const { dialogOpenMode } = props;
const subscription = props.selected;
const handleOpenDialogClick = () => {
props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT);
};
const handleDialogClose = () => {
props.onDialogOpenModeChange("");
setDialogKey((prev) => prev + 1);
};
return (
<>
{subscription && (
<MessageBar subscription={subscription} message={message} onMessageChange={setMessage} onOpenDialogClick={handleOpenDialogClick} />
)}
<PublishDialog
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
openMode={dialogOpenMode}
baseUrl={subscription?.baseUrl ?? config.base_url}
topic={subscription?.topic ?? ""}
message={message}
onClose={handleDialogClose}
onDragEnter={() => props.onDialogOpenModeChange((prev) => prev || PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open
onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}
/>
</>
);
};
const MessageBar = (props) => {
const { t } = useTranslation();
const { subscription } = props;
const [snackOpen, setSnackOpen] = useState(false);
const handleSendClick = async () => {
try {
await api.publish(subscription.baseUrl, subscription.topic, props.message);
} catch (e) {
console.log(`[MessageBar] Error publishing message`, e);
setSnackOpen(true);
}
props.onMessageChange("");
};
return (
<Paper
elevation={3}
sx={{
display: "flex",
position: "fixed",
bottom: 0,
right: 0,
padding: 2,
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]),
}}
>
<IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick} aria-label={t("message_bar_show_dialog")}>
<KeyboardArrowUpIcon />
</IconButton>
<TextField
autoFocus
margin="dense"
placeholder={t("message_bar_type_message")}
aria-label={t("message_bar_type_message")}
role="textbox"
type="text"
fullWidth
variant="standard"
value={props.message}
onChange={(ev) => props.onMessageChange(ev.target.value)}
onKeyPress={(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
handleSendClick();
}
}}
/>
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick} aria-label={t("message_bar_publish")}>
<SendIcon />
</IconButton>
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("message_bar_error_publishing")}
/>
</Portal>
</Paper>
);
};
export default Messaging;

View File

@@ -1,371 +0,0 @@
import Drawer from "@mui/material/Drawer";
import * as React from "react";
import {useContext, useState} from "react";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
import Person from "@mui/icons-material/Person";
import ListItemText from "@mui/material/ListItemText";
import Toolbar from "@mui/material/Toolbar";
import Divider from "@mui/material/Divider";
import List from "@mui/material/List";
import SettingsIcon from "@mui/icons-material/Settings";
import AddIcon from "@mui/icons-material/Add";
import SubscribeDialog from "./SubscribeDialog";
import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Portal, Tooltip} from "@mui/material";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import {openUrl, topicDisplayName, topicUrl} from "../app/utils";
import routes from "./routes";
import {ConnectionState} from "../app/Connection";
import {useLocation, useNavigate} from "react-router-dom";
import subscriptionManager from "../app/SubscriptionManager";
import {ChatBubble, MoreVert, NotificationsOffOutlined, Send} from "@mui/icons-material";
import Box from "@mui/material/Box";
import notifier from "../app/Notifier";
import config from "../app/config";
import ArticleIcon from '@mui/icons-material/Article';
import {Trans, useTranslation} from "react-i18next";
import session from "../app/Session";
import accountApi, {Permission, Role} from "../app/AccountApi";
import CelebrationIcon from '@mui/icons-material/Celebration';
import UpgradeDialog from "./UpgradeDialog";
import {AccountContext} from "./App";
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
import IconButton from "@mui/material/IconButton";
import { SubscriptionPopup } from "./SubscriptionPopup";
const navWidth = 280;
const Navigation = (props) => {
const navigationList = <NavList {...props}/>;
return (
<Box
component="nav"
role="navigation"
sx={{width: {sm: Navigation.width}, flexShrink: {sm: 0}}}
>
{/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
<Drawer
variant="temporary"
role="menubar"
open={props.mobileDrawerOpen}
onClose={props.onMobileDrawerToggle}
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
}}
>
{navigationList}
</Drawer>
{/* Big screen drawer; persistent, shown if screen is big */}
<Drawer
open
variant="permanent"
role="menubar"
sx={{
display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
}}
>
{navigationList}
</Drawer>
</Box>
);
};
Navigation.width = navWidth;
const NavList = (props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { account } = useContext(AccountContext);
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
const handleSubscribeReset = () => {
setSubscribeDialogOpen(false);
setSubscribeDialogKey(prev => prev+1);
}
const handleSubscribeSubmit = (subscription) => {
console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
handleSubscribeReset();
navigate(routes.forSubscription(subscription));
handleRequestNotificationPermission();
}
const handleRequestNotificationPermission = () => {
notifier.maybeRequestPermission(granted => props.onNotificationGranted(granted))
};
const handleAccountClick = () => {
accountApi.sync(); // Dangle!
navigate(routes.account);
};
const isAdmin = account?.role === Role.ADMIN;
const isPaid = account?.billing?.subscription;
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted;
const navListPadding = (showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox) ? '0' : '';
return (
<>
<Toolbar sx={{ display: { xs: 'none', sm: 'block' } }}/>
<List component="nav" sx={{ paddingTop: navListPadding }}>
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert/>}
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
{!showSubscriptionsList &&
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
<ListItemIcon><ChatBubble/></ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")}/>
</ListItemButton>}
{showSubscriptionsList &&
<>
<ListSubheader>{t("nav_topics_title")}</ListSubheader>
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
<ListItemIcon><ChatBubble/></ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")}/>
</ListItemButton>
<SubscriptionList
subscriptions={props.subscriptions}
selectedSubscription={props.selectedSubscription}
/>
<Divider sx={{my: 1}}/>
</>}
{session.exists() &&
<ListItemButton onClick={handleAccountClick} selected={location.pathname === routes.account}>
<ListItemIcon><Person/></ListItemIcon>
<ListItemText primary={t("nav_button_account")}/>
</ListItemButton>
}
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
<ListItemIcon><SettingsIcon/></ListItemIcon>
<ListItemText primary={t("nav_button_settings")}/>
</ListItemButton>
<ListItemButton onClick={() => openUrl("/docs")}>
<ListItemIcon><ArticleIcon/></ListItemIcon>
<ListItemText primary={t("nav_button_documentation")}/>
</ListItemButton>
<ListItemButton onClick={() => props.onPublishMessageClick()}>
<ListItemIcon><Send/></ListItemIcon>
<ListItemText primary={t("nav_button_publish_message")}/>
</ListItemButton>
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
<ListItemIcon><AddIcon/></ListItemIcon>
<ListItemText primary={t("nav_button_subscribe")}/>
</ListItemButton>
{showUpgradeBanner &&
<UpgradeBanner/>
}
</List>
<SubscribeDialog
key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed
open={subscribeDialogOpen}
subscriptions={props.subscriptions}
onCancel={handleSubscribeReset}
onSuccess={handleSubscribeSubmit}
/>
</>
);
};
const UpgradeBanner = () => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const handleClick = () => {
setDialogKey(k => k + 1);
setDialogOpen(true);
};
return (
<Box sx={{
position: "fixed",
width: `${Navigation.width - 1}px`,
bottom: 0,
mt: 'auto',
background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
}}>
<Divider/>
<ListItemButton onClick={handleClick} sx={{pt: 2, pb: 2}}>
<ListItemIcon><CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large"/></ListItemIcon>
<ListItemText
sx={{ ml: 1 }}
primary={t("nav_upgrade_banner_label")}
secondary={t("nav_upgrade_banner_description")}
primaryTypographyProps={{
style: {
fontWeight: 500,
fontSize: "1.1rem",
background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent"
}
}}
secondaryTypographyProps={{
style: {
fontSize: "1rem"
}
}}
/>
</ListItemButton>
<UpgradeDialog
key={`upgradeDialog${dialogKey}`}
open={dialogOpen}
onCancel={() => setDialogOpen(false)}
/>
</Box>
);
};
const SubscriptionList = (props) => {
const sortedSubscriptions = props.subscriptions
.filter(s => !s.internal)
.sort((a, b) => {
return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1;
});
return (
<>
{sortedSubscriptions.map(subscription =>
<SubscriptionItem
key={subscription.id}
subscription={subscription}
selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id}
/>)}
</>
);
}
const SubscriptionItem = (props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
const subscription = props.subscription;
const iconBadge = (subscription.new <= 99) ? subscription.new : "99+";
const displayName = topicDisplayName(subscription);
const ariaLabel = (subscription.state === ConnectionState.Connecting)
? `${displayName} (${t("nav_button_connecting")})`
: displayName;
const icon = (subscription.state === ConnectionState.Connecting)
? <CircularProgress size="24px"/>
: <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>;
const handleClick = async () => {
navigate(routes.forSubscription(subscription));
await subscriptionManager.markNotificationsRead(subscription.id);
};
return (
<>
<ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
<ListItemIcon>{icon}</ListItemIcon>
<ListItemText primary={displayName} primaryTypographyProps={{ style: { overflow: "hidden", textOverflow: "ellipsis" } }}/>
{subscription.reservation?.everyone &&
<ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
{subscription.reservation?.everyone === Permission.READ_WRITE &&
<Tooltip title={t("prefs_reservations_table_everyone_read_write")}><PermissionReadWrite size="small"/></Tooltip>
}
{subscription.reservation?.everyone === Permission.READ_ONLY &&
<Tooltip title={t("prefs_reservations_table_everyone_read_only")}><PermissionRead size="small"/></Tooltip>
}
{subscription.reservation?.everyone === Permission.WRITE_ONLY &&
<Tooltip title={t("prefs_reservations_table_everyone_write_only")}><PermissionWrite size="small"/></Tooltip>
}
{subscription.reservation?.everyone === Permission.DENY_ALL &&
<Tooltip title={t("prefs_reservations_table_everyone_deny_all")}><PermissionDenyAll size="small"/></Tooltip>
}
</ListItemIcon>
}
{subscription.mutedUntil > 0 &&
<ListItemIcon edge="end" sx={{ minWidth: "26px" }} aria-label={t("nav_button_muted")}>
<Tooltip title={t("nav_button_muted")}><NotificationsOffOutlined /></Tooltip>
</ListItemIcon>
}
<ListItemIcon edge="end" sx={{minWidth: "26px"}}>
<IconButton
size="small"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setMenuAnchorEl(e.currentTarget);
}}
>
<MoreVert fontSize="small"/>
</IconButton>
</ListItemIcon>
</ListItemButton>
<Portal>
<SubscriptionPopup
subscription={subscription}
anchor={menuAnchorEl}
onClose={() => setMenuAnchorEl(null)}
/>
</Portal>
</>
);
};
const NotificationGrantAlert = (props) => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{paddingTop: 2}}>
<AlertTitle>{t("alert_grant_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_grant_description")}</Typography>
<Button
sx={{float: 'right'}}
color="inherit"
size="small"
onClick={props.onRequestPermissionClick}
>
{t("alert_grant_button")}
</Button>
</Alert>
<Divider/>
</>
);
};
const NotificationBrowserNotSupportedAlert = () => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{paddingTop: 2}}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_not_supported_description")}</Typography>
</Alert>
<Divider/>
</>
);
};
const NotificationContextNotSupportedAlert = () => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{paddingTop: 2}}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom>
<Trans
i18nKey="alert_not_supported_context_description"
components={{
mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener"/>
}}
/>
</Typography>
</Alert>
<Divider/>
</>
);
};
export default Navigation;

View File

@@ -0,0 +1,396 @@
import {
Drawer,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Divider,
List,
Alert,
AlertTitle,
Badge,
CircularProgress,
Link,
ListSubheader,
Portal,
Tooltip,
Button,
Typography,
Box,
IconButton,
} from "@mui/material";
import * as React from "react";
import { useContext, useState } from "react";
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
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 ArticleIcon from "@mui/icons-material/Article";
import { Trans, useTranslation } from "react-i18next";
import CelebrationIcon from "@mui/icons-material/Celebration";
import SubscribeDialog from "./SubscribeDialog";
import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
import routes from "./routes";
import { ConnectionState } from "../app/Connection";
import subscriptionManager from "../app/SubscriptionManager";
import notifier from "../app/Notifier";
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 { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
import { SubscriptionPopup } from "./SubscriptionPopup";
const navWidth = 280;
const Navigation = (props) => {
const navigationList = <NavList {...props} />;
return (
<Box component="nav" role="navigation" sx={{ width: { sm: Navigation.width }, flexShrink: { sm: 0 } }}>
{/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
<Drawer
variant="temporary"
role="menubar"
open={props.mobileDrawerOpen}
onClose={props.onMobileDrawerToggle}
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
sx={{
display: { xs: "block", sm: "none" },
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
}}
>
{navigationList}
</Drawer>
{/* Big screen drawer; persistent, shown if screen is big */}
<Drawer
open
variant="permanent"
role="menubar"
sx={{
display: { xs: "none", sm: "block" },
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
}}
>
{navigationList}
</Drawer>
</Box>
);
};
Navigation.width = navWidth;
const NavList = (props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { account } = useContext(AccountContext);
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
const handleSubscribeReset = () => {
setSubscribeDialogOpen(false);
setSubscribeDialogKey((prev) => prev + 1);
};
const handleRequestNotificationPermission = () => {
notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted));
};
const handleSubscribeSubmit = (subscription) => {
console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
handleSubscribeReset();
navigate(routes.forSubscription(subscription));
handleRequestNotificationPermission();
};
const handleAccountClick = () => {
accountApi.sync(); // Dangle!
navigate(routes.account);
};
const isAdmin = account?.role === Role.ADMIN;
const isPaid = account?.billing?.subscription;
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted;
const navListPadding =
showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox ? "0" : "";
return (
<>
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
<List component="nav" sx={{ paddingTop: navListPadding }}>
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert />}
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission} />}
{!showSubscriptionsList && (
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
<ListItemIcon>
<ChatBubble />
</ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")} />
</ListItemButton>
)}
{showSubscriptionsList && (
<>
<ListSubheader>{t("nav_topics_title")}</ListSubheader>
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
<ListItemIcon>
<ChatBubble />
</ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")} />
</ListItemButton>
<SubscriptionList subscriptions={props.subscriptions} selectedSubscription={props.selectedSubscription} />
<Divider sx={{ my: 1 }} />
</>
)}
{session.exists() && (
<ListItemButton onClick={handleAccountClick} selected={location.pathname === routes.account}>
<ListItemIcon>
<Person />
</ListItemIcon>
<ListItemText primary={t("nav_button_account")} />
</ListItemButton>
)}
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_settings")} />
</ListItemButton>
<ListItemButton onClick={() => openUrl("/docs")}>
<ListItemIcon>
<ArticleIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_documentation")} />
</ListItemButton>
<ListItemButton onClick={() => props.onPublishMessageClick()}>
<ListItemIcon>
<Send />
</ListItemIcon>
<ListItemText primary={t("nav_button_publish_message")} />
</ListItemButton>
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
<ListItemIcon>
<AddIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_subscribe")} />
</ListItemButton>
{showUpgradeBanner && <UpgradeBanner />}
</List>
<SubscribeDialog
key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed
open={subscribeDialogOpen}
subscriptions={props.subscriptions}
onCancel={handleSubscribeReset}
onSuccess={handleSubscribeSubmit}
/>
</>
);
};
const UpgradeBanner = () => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const handleClick = () => {
setDialogKey((k) => k + 1);
setDialogOpen(true);
};
return (
<Box
sx={{
position: "fixed",
width: `${Navigation.width - 1}px`,
bottom: 0,
mt: "auto",
background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
}}
>
<Divider />
<ListItemButton onClick={handleClick} sx={{ pt: 2, pb: 2 }}>
<ListItemIcon>
<CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large" />
</ListItemIcon>
<ListItemText
sx={{ ml: 1 }}
primary={t("nav_upgrade_banner_label")}
secondary={t("nav_upgrade_banner_description")}
primaryTypographyProps={{
style: {
fontWeight: 500,
fontSize: "1.1rem",
background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
},
}}
secondaryTypographyProps={{
style: {
fontSize: "1rem",
},
}}
/>
</ListItemButton>
<UpgradeDialog key={`upgradeDialog${dialogKey}`} open={dialogOpen} onCancel={() => setDialogOpen(false)} />
</Box>
);
};
const SubscriptionList = (props) => {
const sortedSubscriptions = props.subscriptions
.filter((s) => !s.internal)
.sort((a, b) => (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1));
return (
<>
{sortedSubscriptions.map((subscription) => (
<SubscriptionItem
key={subscription.id}
subscription={subscription}
selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id}
/>
))}
</>
);
};
const SubscriptionItem = (props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
const { subscription } = props;
const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
const displayName = topicDisplayName(subscription);
const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName;
const icon =
subscription.state === ConnectionState.Connecting ? (
<CircularProgress size="24px" />
) : (
<Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary">
<ChatBubbleOutlineIcon />
</Badge>
);
const handleClick = async () => {
navigate(routes.forSubscription(subscription));
await subscriptionManager.markNotificationsRead(subscription.id);
};
return (
<>
<ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
<ListItemIcon>{icon}</ListItemIcon>
<ListItemText
primary={displayName}
primaryTypographyProps={{
style: { overflow: "hidden", textOverflow: "ellipsis" },
}}
/>
{subscription.reservation?.everyone && (
<ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
{subscription.reservation?.everyone === Permission.READ_WRITE && (
<Tooltip title={t("prefs_reservations_table_everyone_read_write")}>
<PermissionReadWrite size="small" />
</Tooltip>
)}
{subscription.reservation?.everyone === Permission.READ_ONLY && (
<Tooltip title={t("prefs_reservations_table_everyone_read_only")}>
<PermissionRead size="small" />
</Tooltip>
)}
{subscription.reservation?.everyone === Permission.WRITE_ONLY && (
<Tooltip title={t("prefs_reservations_table_everyone_write_only")}>
<PermissionWrite size="small" />
</Tooltip>
)}
{subscription.reservation?.everyone === Permission.DENY_ALL && (
<Tooltip title={t("prefs_reservations_table_everyone_deny_all")}>
<PermissionDenyAll size="small" />
</Tooltip>
)}
</ListItemIcon>
)}
{subscription.mutedUntil > 0 && (
<ListItemIcon edge="end" sx={{ minWidth: "26px" }} aria-label={t("nav_button_muted")}>
<Tooltip title={t("nav_button_muted")}>
<NotificationsOffOutlined />
</Tooltip>
</ListItemIcon>
)}
<ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
<IconButton
size="small"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setMenuAnchorEl(e.currentTarget);
}}
>
<MoreVert fontSize="small" />
</IconButton>
</ListItemIcon>
</ListItemButton>
<Portal>
<SubscriptionPopup subscription={subscription} anchor={menuAnchorEl} onClose={() => setMenuAnchorEl(null)} />
</Portal>
</>
);
};
const NotificationGrantAlert = (props) => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_grant_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_grant_description")}</Typography>
<Button sx={{ float: "right" }} color="inherit" size="small" onClick={props.onRequestPermissionClick}>
{t("alert_grant_button")}
</Button>
</Alert>
<Divider />
</>
);
};
const NotificationBrowserNotSupportedAlert = () => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_not_supported_description")}</Typography>
</Alert>
<Divider />
</>
);
};
const NotificationContextNotSupportedAlert = () => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom>
<Trans
i18nKey="alert_not_supported_context_description"
components={{
mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener" />,
}}
/>
</Typography>
</Alert>
<Divider />
</>
);
};
export default Navigation;

View File

@@ -1,548 +0,0 @@
import Container from "@mui/material/Container";
import {
ButtonBase,
CardActions,
CardContent,
CircularProgress,
Fade,
Link,
Modal,
Snackbar,
Stack,
Tooltip
} from "@mui/material";
import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography";
import * as React from "react";
import {useEffect, useState} from "react";
import {
formatBytes,
formatMessage,
formatShortDateTime,
formatTitle,
maybeAppendActionErrors,
openUrl,
shortUrl,
topicShortUrl,
unmatchedTags
} from "../app/utils";
import IconButton from "@mui/material/IconButton";
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles";
import {useLiveQuery} from "dexie-react-hooks";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import subscriptionManager from "../app/SubscriptionManager";
import InfiniteScroll from "react-infinite-scroll-component";
import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import logoOutline from "../img/ntfy-outline.svg";
import AttachmentIcon from "./AttachmentIcon";
import {Trans, useTranslation} from "react-i18next";
import {useOutletContext} from "react-router-dom";
import {useAutoSubscribe} from "./hooks";
export const AllSubscriptions = () => {
const { subscriptions } = useOutletContext();
if (!subscriptions) {
return <Loading/>;
}
return <AllSubscriptionsList subscriptions={subscriptions}/>;
};
export const SingleSubscription = () => {
const { subscriptions, selected } = useOutletContext();
useAutoSubscribe(subscriptions, selected);
if (!selected) {
return <Loading/>;
}
return <SingleSubscriptionList subscription={selected}/>;
};
const AllSubscriptionsList = (props) => {
const subscriptions = props.subscriptions;
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
if (notifications === null || notifications === undefined) {
return <Loading/>;
} else if (subscriptions.length === 0) {
return <NoSubscriptions/>;
} else if (notifications.length === 0) {
return <NoNotificationsWithoutSubscription subscriptions={subscriptions}/>;
}
return <NotificationList key="all" notifications={notifications} messageBar={false}/>;
}
const SingleSubscriptionList = (props) => {
const subscription = props.subscription;
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
if (notifications === null || notifications === undefined) {
return <Loading/>;
} else if (notifications.length === 0) {
return <NoNotifications subscription={subscription}/>;
}
return <NotificationList id={subscription.id} notifications={notifications} messageBar={true}/>;
}
const NotificationList = (props) => {
const { t } = useTranslation();
const pageSize = 20;
const notifications = props.notifications;
const [snackOpen, setSnackOpen] = useState(false);
const [maxCount, setMaxCount] = useState(pageSize);
const count = Math.min(notifications.length, maxCount);
useEffect(() => {
return () => {
setMaxCount(pageSize);
const main = document.getElementById("main");
if (main) {
main.scrollTo(0, 0);
}
}
}, [props.id]);
return (
<InfiniteScroll
dataLength={count}
next={() => setMaxCount(prev => prev + pageSize)}
hasMore={count < notifications.length}
loader={<>Loading ...</>}
scrollThreshold={0.7}
scrollableTarget="main"
>
<Container
maxWidth="md"
role="list"
aria-label={t("notifications_list")}
sx={{
marginTop: 3,
marginBottom: (props.messageBar) ? "100px" : 3 // Hack to avoid hiding notifications behind the message bar
}}
>
<Stack spacing={3}>
{notifications.slice(0, count).map(notification =>
<NotificationItem
key={notification.id}
notification={notification}
onShowSnack={() => setSnackOpen(true)}
/>)}
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("notifications_copied_to_clipboard")}
/>
</Stack>
</Container>
</InfiniteScroll>
);
}
const NotificationItem = (props) => {
const { t } = useTranslation();
const notification = props.notification;
const attachment = notification.attachment;
const date = formatShortDateTime(notification.time);
const otherTags = unmatchedTags(notification.tags);
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
const handleDelete = async () => {
console.log(`[Notifications] Deleting notification ${notification.id}`);
await subscriptionManager.deleteNotification(notification.id)
}
const handleMarkRead = async () => {
console.log(`[Notifications] Marking notification ${notification.id} as read`);
await subscriptionManager.markNotificationRead(notification.id)
}
const handleCopy = (s) => {
navigator.clipboard.writeText(s);
props.onShowSnack();
};
const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000;
const hasAttachmentActions = attachment && !expired;
const hasClickAction = notification.click;
const hasUserActions = notification.actions && notification.actions.length > 0;
const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
return (
<Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
<CardContent>
<Tooltip title={t("notifications_delete")} enterDelay={500}>
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}>
<CloseIcon />
</IconButton>
</Tooltip>
{notification.new === 1 &&
<Tooltip title={t("notifications_mark_read")} enterDelay={500}>
<IconButton onClick={handleMarkRead} sx={{ float: 'right', marginRight: -0.5, marginTop: -1 }} aria-label={t("notifications_mark_read")}>
<CheckIcon />
</IconButton>
</Tooltip>}
<Typography sx={{ fontSize: 14 }} color="text.secondary">
{date}
{[1,2,4,5].includes(notification.priority) &&
<img
src={priorityFiles[notification.priority]}
alt={t("notifications_priority_x", { priority: notification.priority})}
style={{ verticalAlign: 'bottom' }}
/>}
{notification.new === 1 &&
<svg style={{ width: '8px', height: '8px', marginLeft: '4px' }} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-label={t("notifications_new_indicator")}>
<circle cx="50" cy="50" r="50" fill="#338574"/>
</svg>}
</Typography>
{notification.title && <Typography variant="h5" component="div" role="rowheader">{formatTitle(notification)}</Typography>}
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>
{autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
</Typography>
{attachment && <Attachment attachment={attachment}/>}
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">{t("notifications_tags")}: {tags}</Typography>}
</CardContent>
{showActions &&
<CardActions sx={{paddingTop: 0}}>
{hasAttachmentActions && <>
<Tooltip title={t("notifications_attachment_copy_url_title")}>
<Button onClick={() => handleCopy(attachment.url)}>{t("notifications_attachment_copy_url_button")}</Button>
</Tooltip>
<Tooltip title={t("notifications_attachment_open_title", { url: attachment.url })}>
<Button onClick={() => openUrl(attachment.url)}>{t("notifications_attachment_open_button")}</Button>
</Tooltip>
</>}
{hasClickAction && <>
<Tooltip title={t("notifications_click_copy_url_title")}>
<Button onClick={() => handleCopy(notification.click)}>{t("notifications_click_copy_url_button")}</Button>
</Tooltip>
<Tooltip title={t("notifications_actions_open_url_title", { url: notification.click })}>
<Button onClick={() => openUrl(notification.click)}>{t("notifications_click_open_button")}</Button>
</Tooltip>
</>}
{hasUserActions && <UserActions notification={notification}/>}
</CardActions>}
</Card>
);
}
/**
* Replace links with <Link/> components; this is a combination of the genius function
* in [1] and the regex in [2].
*
* [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760
* [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9
*/
const autolink = (s) => {
const parts = s.split(/(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi);
for (let i = 1; i < parts.length; i += 2) {
parts[i] = <Link key={i} href={parts[i]} underline="hover" target="_blank" rel="noreferrer,noopener">{shortUrl(parts[i])}</Link>;
}
return <>{parts}</>;
};
const priorityFiles = {
1: priority1,
2: priority2,
4: priority4,
5: priority5
};
const Attachment = (props) => {
const { t } = useTranslation();
const attachment = props.attachment;
const expired = attachment.expires && attachment.expires < Date.now()/1000;
const expires = attachment.expires && attachment.expires > Date.now()/1000;
const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
// Unexpired image
if (displayableImage) {
return <Image attachment={attachment}/>;
}
// Anything else: Show box
const infos = [];
if (attachment.size) {
infos.push(formatBytes(attachment.size));
}
if (expires) {
infos.push(t("notifications_attachment_link_expires", { date: formatShortDateTime(attachment.expires) }));
}
if (expired) {
infos.push(t("notifications_attachment_link_expired"));
}
const maybeInfoText = (infos.length > 0) ? <><br/>{infos.join(", ")}</> : null;
// If expired, just show infos without click target
if (expired) {
return (
<Box sx={{
display: 'flex',
alignItems: 'center',
marginTop: 2,
padding: 1,
borderRadius: '4px',
}}>
<AttachmentIcon type={attachment.type}/>
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
<b>{attachment.name}</b>
{maybeInfoText}
</Typography>
</Box>
);
}
// Not expired
return (
<ButtonBase sx={{
marginTop: 2,
}}>
<Link
href={attachment.url}
target="_blank"
rel="noopener"
underline="none"
sx={{
display: 'flex',
alignItems: 'center',
padding: 1,
borderRadius: '4px',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.05)'
}
}}
>
<AttachmentIcon type={attachment.type}/>
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
<b>{attachment.name}</b>
{maybeInfoText}
</Typography>
</Link>
</ButtonBase>
);
};
const Image = (props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<>
<Box
component="img"
src={props.attachment.url}
loading="lazy"
alt={t("notifications_attachment_image")}
onClick={() => setOpen(true)}
sx={{
marginTop: 2,
borderRadius: '4px',
boxShadow: 2,
width: 1,
maxHeight: '400px',
objectFit: 'cover',
cursor: 'pointer'
}}
/>
<Modal
open={open}
onClose={() => setOpen(false)}
BackdropComponent={LightboxBackdrop}
>
<Fade in={open}>
<Box
component="img"
src={props.attachment.url}
alt={t("notifications_attachment_image")}
loading="lazy"
sx={{
maxWidth: 1,
maxHeight: 1,
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: 4,
}}
/>
</Fade>
</Modal>
</>
);
}
const UserActions = (props) => {
return (
<>{props.notification.actions.map(action =>
<UserAction key={action.id} notification={props.notification} action={action}/>)}</>
);
};
const UserAction = (props) => {
const { t } = useTranslation();
const notification = props.notification;
const action = props.action;
if (action.action === "broadcast") {
return (
<Tooltip title={t("notifications_actions_not_supported")}>
<span><Button disabled aria-label={t("notifications_actions_not_supported")}>{action.label}</Button></span>
</Tooltip>
);
} else if (action.action === "view") {
return (
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
<Button
onClick={() => openUrl(action.url)}
aria-label={t("notifications_actions_open_url_title", { url: action.url })}
>{action.label}</Button>
</Tooltip>
);
} else if (action.action === "http") {
const method = action.method ?? "POST";
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
return (
<Tooltip title={t("notifications_actions_http_request_title", { method: method, url: action.url })}>
<Button
onClick={() => performHttpAction(notification, action)}
aria-label={t("notifications_actions_http_request_title", { method: method, url: action.url })}
>{label}</Button>
</Tooltip>
);
}
return null; // Others
};
const performHttpAction = async (notification, action) => {
console.log(`[Notifications] Performing HTTP user action`, action);
try {
updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null);
const response = await fetch(action.url, {
method: action.method ?? "POST",
headers: action.headers ?? {},
// This must not null-coalesce to a non nullish value. Otherwise, the fetch API
// will reject it for "having a body"
body: action.body
});
console.log(`[Notifications] HTTP user action response`, response);
const success = response.status >= 200 && response.status <= 299;
if (success) {
updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null);
} else {
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`);
}
} catch (e) {
console.log(`[Notifications] HTTP action failed`, e);
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`);
}
};
const updateActionStatus = (notification, action, progress, error) => {
notification.actions = notification.actions.map(a => {
if (a.id !== action.id) {
return a;
}
return { ...a, progress: progress, error: error };
});
subscriptionManager.updateNotification(notification);
}
const ACTION_PROGRESS_ONGOING = 1;
const ACTION_PROGRESS_SUCCESS = 2;
const ACTION_PROGRESS_FAILED = 3;
const ACTION_LABEL_SUFFIX = {
[ACTION_PROGRESS_ONGOING]: " …",
[ACTION_PROGRESS_SUCCESS]: " ✔",
[ACTION_PROGRESS_FAILED]: " ❌"
};
const NoNotifications = (props) => {
const { t } = useTranslation();
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
{t("notifications_none_for_topic_title")}
</Typography>
<Paragraph>
{t("notifications_none_for_topic_description")}
</Paragraph>
<Paragraph>
{t("notifications_example")}:<br/>
<tt>
$ curl -d "Hi" {shortUrl}
</tt>
</Paragraph>
<Paragraph>
<ForMoreDetails/>
</Paragraph>
</VerticallyCenteredContainer>
);
};
const NoNotificationsWithoutSubscription = (props) => {
const { t } = useTranslation();
const subscription = props.subscriptions[0];
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
{t("notifications_none_for_any_title")}
</Typography>
<Paragraph>
{t("notifications_none_for_any_description")}
</Paragraph>
<Paragraph>
{t("notifications_example")}:<br/>
<tt>
$ curl -d "Hi" {shortUrl}
</tt>
</Paragraph>
<Paragraph>
<ForMoreDetails/>
</Paragraph>
</VerticallyCenteredContainer>
);
};
const NoSubscriptions = () => {
const { t } = useTranslation();
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
{t("notifications_no_subscriptions_title")}
</Typography>
<Paragraph>
{t("notifications_no_subscriptions_description", {
linktext: t("nav_button_subscribe")
})}
</Paragraph>
<Paragraph>
<ForMoreDetails/>
</Paragraph>
</VerticallyCenteredContainer>
);
};
const ForMoreDetails = () => {
return (
<Trans
i18nKey="notifications_more_details"
components={{
websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener"/>,
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/>
}}
/>
);
};
const Loading = () => {
const { t } = useTranslation();
return (
<VerticallyCenteredContainer>
<Typography variant="h5" color="text.secondary" align="center" sx={{ paddingBottom: 1 }}>
<CircularProgress disableShrink sx={{marginBottom: 1}}/><br />
{t("notifications_loading")}
</Typography>
</VerticallyCenteredContainer>
);
};

View File

@@ -0,0 +1,616 @@
import {
Container,
ButtonBase,
CardActions,
CardContent,
CircularProgress,
Fade,
Link,
Modal,
Snackbar,
Stack,
Tooltip,
Card,
Typography,
IconButton,
Box,
Button,
} from "@mui/material";
import * as React from "react";
import { useEffect, useState } from "react";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import { useLiveQuery } from "dexie-react-hooks";
import InfiniteScroll from "react-infinite-scroll-component";
import { Trans, useTranslation } from "react-i18next";
import { useOutletContext } from "react-router-dom";
import {
formatBytes,
formatMessage,
formatShortDateTime,
formatTitle,
maybeAppendActionErrors,
openUrl,
shortUrl,
topicShortUrl,
unmatchedTags,
} from "../app/utils";
import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
import subscriptionManager from "../app/SubscriptionManager";
import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import logoOutline from "../img/ntfy-outline.svg";
import AttachmentIcon from "./AttachmentIcon";
import { useAutoSubscribe } from "./hooks";
const priorityFiles = {
1: priority1,
2: priority2,
4: priority4,
5: priority5,
};
export const AllSubscriptions = () => {
const { subscriptions } = useOutletContext();
if (!subscriptions) {
return <Loading />;
}
return <AllSubscriptionsList subscriptions={subscriptions} />;
};
export const SingleSubscription = () => {
const { subscriptions, selected } = useOutletContext();
useAutoSubscribe(subscriptions, selected);
if (!selected) {
return <Loading />;
}
return <SingleSubscriptionList subscription={selected} />;
};
const AllSubscriptionsList = (props) => {
const { subscriptions } = props;
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
if (notifications === null || notifications === undefined) {
return <Loading />;
}
if (subscriptions.length === 0) {
return <NoSubscriptions />;
}
if (notifications.length === 0) {
return <NoNotificationsWithoutSubscription subscriptions={subscriptions} />;
}
return <NotificationList key="all" notifications={notifications} messageBar={false} />;
};
const SingleSubscriptionList = (props) => {
const { subscription } = props;
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
if (notifications === null || notifications === undefined) {
return <Loading />;
}
if (notifications.length === 0) {
return <NoNotifications subscription={subscription} />;
}
return <NotificationList id={subscription.id} notifications={notifications} messageBar />;
};
const NotificationList = (props) => {
const { t } = useTranslation();
const pageSize = 20;
const { notifications } = props;
const [snackOpen, setSnackOpen] = useState(false);
const [maxCount, setMaxCount] = useState(pageSize);
const count = Math.min(notifications.length, maxCount);
useEffect(
() => () => {
setMaxCount(pageSize);
const main = document.getElementById("main");
if (main) {
main.scrollTo(0, 0);
}
},
[props.id]
);
return (
<InfiniteScroll
dataLength={count}
next={() => setMaxCount((prev) => prev + pageSize)}
hasMore={count < notifications.length}
loader={<>Loading ...</>}
scrollThreshold={0.7}
scrollableTarget="main"
>
<Container
maxWidth="md"
role="list"
aria-label={t("notifications_list")}
sx={{
marginTop: 3,
marginBottom: props.messageBar ? "100px" : 3, // Hack to avoid hiding notifications behind the message bar
}}
>
<Stack spacing={3}>
{notifications.slice(0, count).map((notification) => (
<NotificationItem key={notification.id} notification={notification} onShowSnack={() => setSnackOpen(true)} />
))}
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("notifications_copied_to_clipboard")}
/>
</Stack>
</Container>
</InfiniteScroll>
);
};
/**
* Replace links with <Link/> components; this is a combination of the genius function
* in [1] and the regex in [2].
*
* [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760
* [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9
*/
const autolink = (s) => {
const parts = s.split(/(\bhttps?:\/\/[-A-Z0-9+\u0026\u2019@#/%?=()~_|!:,.;]*[-A-Z0-9+\u0026@#/%=~()_|]\b)/gi);
for (let i = 1; i < parts.length; i += 2) {
parts[i] = (
<Link key={i} href={parts[i]} underline="hover" target="_blank" rel="noreferrer,noopener">
{shortUrl(parts[i])}
</Link>
);
}
return <>{parts}</>;
};
const NotificationItem = (props) => {
const { t } = useTranslation();
const { notification } = props;
const { attachment } = notification;
const date = formatShortDateTime(notification.time);
const otherTags = unmatchedTags(notification.tags);
const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
const handleDelete = async () => {
console.log(`[Notifications] Deleting notification ${notification.id}`);
await subscriptionManager.deleteNotification(notification.id);
};
const handleMarkRead = async () => {
console.log(`[Notifications] Marking notification ${notification.id} as read`);
await subscriptionManager.markNotificationRead(notification.id);
};
const handleCopy = (s) => {
navigator.clipboard.writeText(s);
props.onShowSnack();
};
const expired = attachment && attachment.expires && attachment.expires < Date.now() / 1000;
const hasAttachmentActions = attachment && !expired;
const hasClickAction = notification.click;
const hasUserActions = notification.actions && notification.actions.length > 0;
const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
return (
<Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
<CardContent>
<Tooltip title={t("notifications_delete")} enterDelay={500}>
<IconButton onClick={handleDelete} sx={{ float: "right", marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}>
<CloseIcon />
</IconButton>
</Tooltip>
{notification.new === 1 && (
<Tooltip title={t("notifications_mark_read")} enterDelay={500}>
<IconButton
onClick={handleMarkRead}
sx={{ float: "right", marginRight: -0.5, marginTop: -1 }}
aria-label={t("notifications_mark_read")}
>
<CheckIcon />
</IconButton>
</Tooltip>
)}
<Typography sx={{ fontSize: 14 }} color="text.secondary">
{date}
{[1, 2, 4, 5].includes(notification.priority) && (
<img
src={priorityFiles[notification.priority]}
alt={t("notifications_priority_x", {
priority: notification.priority,
})}
style={{ verticalAlign: "bottom" }}
/>
)}
{notification.new === 1 && (
<svg
style={{ width: "8px", height: "8px", marginLeft: "4px" }}
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
aria-label={t("notifications_new_indicator")}
>
<circle cx="50" cy="50" r="50" fill="#338574" />
</svg>
)}
</Typography>
{notification.title && (
<Typography variant="h5" component="div" role="rowheader">
{formatTitle(notification)}
</Typography>
)}
<Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
{autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
</Typography>
{attachment && <Attachment attachment={attachment} />}
{tags && (
<Typography sx={{ fontSize: 14 }} color="text.secondary">
{t("notifications_tags")}: {tags}
</Typography>
)}
</CardContent>
{showActions && (
<CardActions sx={{ paddingTop: 0 }}>
{hasAttachmentActions && (
<>
<Tooltip title={t("notifications_attachment_copy_url_title")}>
<Button onClick={() => handleCopy(attachment.url)}>{t("notifications_attachment_copy_url_button")}</Button>
</Tooltip>
<Tooltip
title={t("notifications_attachment_open_title", {
url: attachment.url,
})}
>
<Button onClick={() => openUrl(attachment.url)}>{t("notifications_attachment_open_button")}</Button>
</Tooltip>
</>
)}
{hasClickAction && (
<>
<Tooltip title={t("notifications_click_copy_url_title")}>
<Button onClick={() => handleCopy(notification.click)}>{t("notifications_click_copy_url_button")}</Button>
</Tooltip>
<Tooltip
title={t("notifications_actions_open_url_title", {
url: notification.click,
})}
>
<Button onClick={() => openUrl(notification.click)}>{t("notifications_click_open_button")}</Button>
</Tooltip>
</>
)}
{hasUserActions && <UserActions notification={notification} />}
</CardActions>
)}
</Card>
);
};
const Attachment = (props) => {
const { t } = useTranslation();
const { attachment } = props;
const expired = attachment.expires && attachment.expires < Date.now() / 1000;
const expires = attachment.expires && attachment.expires > Date.now() / 1000;
const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
// Unexpired image
if (displayableImage) {
return <Image attachment={attachment} />;
}
// Anything else: Show box
const infos = [];
if (attachment.size) {
infos.push(formatBytes(attachment.size));
}
if (expires) {
infos.push(
t("notifications_attachment_link_expires", {
date: formatShortDateTime(attachment.expires),
})
);
}
if (expired) {
infos.push(t("notifications_attachment_link_expired"));
}
const maybeInfoText =
infos.length > 0 ? (
<>
<br />
{infos.join(", ")}
</>
) : null;
// If expired, just show infos without click target
if (expired) {
return (
<Box
sx={{
display: "flex",
alignItems: "center",
marginTop: 2,
padding: 1,
borderRadius: "4px",
}}
>
<AttachmentIcon type={attachment.type} />
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}>
<b>{attachment.name}</b>
{maybeInfoText}
</Typography>
</Box>
);
}
// Not expired
return (
<ButtonBase
sx={{
marginTop: 2,
}}
>
<Link
href={attachment.url}
target="_blank"
rel="noopener"
underline="none"
sx={{
display: "flex",
alignItems: "center",
padding: 1,
borderRadius: "4px",
"&:hover": {
backgroundColor: "rgba(0, 0, 0, 0.05)",
},
}}
>
<AttachmentIcon type={attachment.type} />
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}>
<b>{attachment.name}</b>
{maybeInfoText}
</Typography>
</Link>
</ButtonBase>
);
};
const Image = (props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<>
<Box
component="img"
src={props.attachment.url}
loading="lazy"
alt={t("notifications_attachment_image")}
onClick={() => setOpen(true)}
sx={{
marginTop: 2,
borderRadius: "4px",
boxShadow: 2,
width: 1,
maxHeight: "400px",
objectFit: "cover",
cursor: "pointer",
}}
/>
<Modal open={open} onClose={() => setOpen(false)} BackdropComponent={LightboxBackdrop}>
<Fade in={open}>
<Box
component="img"
src={props.attachment.url}
alt={t("notifications_attachment_image")}
loading="lazy"
sx={{
maxWidth: 1,
maxHeight: 1,
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
padding: 4,
}}
/>
</Fade>
</Modal>
</>
);
};
const UserActions = (props) => (
<>
{props.notification.actions.map((action) => (
<UserAction key={action.id} notification={props.notification} action={action} />
))}
</>
);
const ACTION_PROGRESS_ONGOING = 1;
const ACTION_PROGRESS_SUCCESS = 2;
const ACTION_PROGRESS_FAILED = 3;
const ACTION_LABEL_SUFFIX = {
[ACTION_PROGRESS_ONGOING]: " …",
[ACTION_PROGRESS_SUCCESS]: " ✔",
[ACTION_PROGRESS_FAILED]: " ❌",
};
const updateActionStatus = (notification, action, progress, error) => {
subscriptionManager.updateNotification({
...notification,
actions: notification.actions.map((a) => (a.id === action.id ? { ...a, progress, error } : a)),
});
};
const performHttpAction = async (notification, action) => {
console.log(`[Notifications] Performing HTTP user action`, action);
try {
updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null);
const response = await fetch(action.url, {
method: action.method ?? "POST",
headers: action.headers ?? {},
// This must not null-coalesce to a non nullish value. Otherwise, the fetch API
// will reject it for "having a body"
body: action.body,
});
console.log(`[Notifications] HTTP user action response`, response);
const success = response.status >= 200 && response.status <= 299;
if (success) {
updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null);
} else {
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`);
}
} catch (e) {
console.log(`[Notifications] HTTP action failed`, e);
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`);
}
};
const UserAction = (props) => {
const { t } = useTranslation();
const { notification } = props;
const { action } = props;
if (action.action === "broadcast") {
return (
<Tooltip title={t("notifications_actions_not_supported")}>
<span>
<Button disabled aria-label={t("notifications_actions_not_supported")}>
{action.label}
</Button>
</span>
</Tooltip>
);
}
if (action.action === "view") {
return (
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
<Button
onClick={() => openUrl(action.url)}
aria-label={t("notifications_actions_open_url_title", {
url: action.url,
})}
>
{action.label}
</Button>
</Tooltip>
);
}
if (action.action === "http") {
const method = action.method ?? "POST";
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
return (
<Tooltip
title={t("notifications_actions_http_request_title", {
method,
url: action.url,
})}
>
<Button
onClick={() => performHttpAction(notification, action)}
aria-label={t("notifications_actions_http_request_title", {
method,
url: action.url,
})}
>
{label}
</Button>
</Tooltip>
);
}
return null; // Others
};
const NoNotifications = (props) => {
const { t } = useTranslation();
const topicShortUrlResolved = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")} />
<br />
{t("notifications_none_for_topic_title")}
</Typography>
<Paragraph>{t("notifications_none_for_topic_description")}</Paragraph>
<Paragraph>
{t("notifications_example")}:<br />
<tt>
{'$ curl -d "Hi" '}
{topicShortUrlResolved}
</tt>
</Paragraph>
<Paragraph>
<ForMoreDetails />
</Paragraph>
</VerticallyCenteredContainer>
);
};
const NoNotificationsWithoutSubscription = (props) => {
const { t } = useTranslation();
const subscription = props.subscriptions[0];
const topicShortUrlResolved = topicShortUrl(subscription.baseUrl, subscription.topic);
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")} />
<br />
{t("notifications_none_for_any_title")}
</Typography>
<Paragraph>{t("notifications_none_for_any_description")}</Paragraph>
<Paragraph>
{t("notifications_example")}:<br />
<tt>
{'$ curl -d "Hi" '}
{topicShortUrlResolved}
</tt>
</Paragraph>
<Paragraph>
<ForMoreDetails />
</Paragraph>
</VerticallyCenteredContainer>
);
};
const NoSubscriptions = () => {
const { t } = useTranslation();
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")} />
<br />
{t("notifications_no_subscriptions_title")}
</Typography>
<Paragraph>
{t("notifications_no_subscriptions_description", {
linktext: t("nav_button_subscribe"),
})}
</Paragraph>
<Paragraph>
<ForMoreDetails />
</Paragraph>
</VerticallyCenteredContainer>
);
};
const ForMoreDetails = () => (
<Trans
i18nKey="notifications_more_details"
components={{
websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener" />,
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />,
}}
/>
);
const Loading = () => {
const { t } = useTranslation();
return (
<VerticallyCenteredContainer>
<Typography variant="h5" color="text.secondary" align="center" sx={{ paddingBottom: 1 }}>
<CircularProgress disableShrink sx={{ marginBottom: 1 }} />
<br />
{t("notifications_loading")}
</Typography>
</VerticallyCenteredContainer>
);
};

View File

@@ -1,48 +0,0 @@
import {Fade, Menu} from "@mui/material";
import * as React from "react";
const PopupMenu = (props) => {
const horizontal = props.horizontal ?? "left";
const arrow = (horizontal === "right") ? { right: 19 } : { left: 19 };
return (
<Menu
anchorEl={props.anchorEl}
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
},
},
}}
transformOrigin={{ horizontal: horizontal, vertical: 'top' }}
anchorOrigin={{ horizontal: horizontal, vertical: 'bottom' }}
>
{props.children}
</Menu>
);
};
export default PopupMenu;

View File

@@ -0,0 +1,48 @@
import { Fade, Menu } from "@mui/material";
import * as React from "react";
const PopupMenu = (props) => {
const horizontal = props.horizontal ?? "left";
const arrow = horizontal === "right" ? { right: 19 } : { left: 19 };
return (
<Menu
anchorEl={props.anchorEl}
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,
},
},
}}
transformOrigin={{ horizontal, vertical: "top" }}
anchorOrigin={{ horizontal, vertical: "bottom" }}
>
{props.children}
</Menu>
);
};
export default PopupMenu;

View File

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

View File

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

View File

@@ -1,654 +0,0 @@
import * as React from 'react';
import {useContext, useEffect, useState} from 'react';
import {
Alert,
CardActions,
CardContent,
Chip,
FormControl,
Select,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tooltip,
useMediaQuery
} from "@mui/material";
import Typography from "@mui/material/Typography";
import prefs from "../app/Prefs";
import {Paragraph} from "./styles";
import EditIcon from '@mui/icons-material/Edit';
import CloseIcon from "@mui/icons-material/Close";
import IconButton from "@mui/material/IconButton";
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import Container from "@mui/material/Container";
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
import Card from "@mui/material/Card";
import Button from "@mui/material/Button";
import {useLiveQuery} from "dexie-react-hooks";
import theme from "./theme";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import userManager from "../app/UserManager";
import {playSound, shuffle, sounds, validUrl} from "../app/utils";
import {useTranslation} from "react-i18next";
import session from "../app/Session";
import routes from "./routes";
import accountApi, {Permission, Role} from "../app/AccountApi";
import {Pref, PrefGroup} from "./Pref";
import {Info} from "@mui/icons-material";
import {AccountContext} from "./App";
import {useOutletContext} from "react-router-dom";
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs";
import {UnauthorizedError} from "../app/errors";
import subscriptionManager from "../app/SubscriptionManager";
import {subscribeTopic} from "./SubscribeDialog";
const Preferences = () => {
return (
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
<Stack spacing={3}>
<Notifications/>
<Reservations/>
<Users/>
<Appearance/>
</Stack>
</Container>
);
};
const Notifications = () => {
const { t } = useTranslation();
return (
<Card sx={{p: 3}} aria-label={t("prefs_notifications_title")}>
<Typography variant="h5" sx={{marginBottom: 2}}>
{t("prefs_notifications_title")}
</Typography>
<PrefGroup>
<Sound/>
<MinPriority/>
<DeleteAfter/>
</PrefGroup>
</Card>
);
};
const Sound = () => {
const { t } = useTranslation();
const labelId = "prefSound";
const sound = useLiveQuery(async () => prefs.sound());
const handleChange = async (ev) => {
await prefs.setSound(ev.target.value);
await maybeUpdateAccountSettings({
notification: {
sound: ev.target.value
}
});
}
if (!sound) {
return null; // While loading
}
let description;
if (sound === "none") {
description = t("prefs_notifications_sound_description_none");
} else {
description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label });
}
return (
<Pref labelId={labelId} title={t("prefs_notifications_sound_title")} description={description}>
<div style={{ display: 'flex', width: '100%' }}>
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
<Select value={sound} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem>
{Object.entries(sounds).map(s => <MenuItem key={s[0]} value={s[0]}>{s[1].label}</MenuItem>)}
</Select>
</FormControl>
<IconButton onClick={() => playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}>
<PlayArrowIcon />
</IconButton>
</div>
</Pref>
)
};
const MinPriority = () => {
const { t } = useTranslation();
const labelId = "prefMinPriority";
const minPriority = useLiveQuery(async () => prefs.minPriority());
const handleChange = async (ev) => {
await prefs.setMinPriority(ev.target.value);
await maybeUpdateAccountSettings({
notification: {
min_priority: ev.target.value
}
});
}
if (!minPriority) {
return null; // While loading
}
const priorities = {
1: t("priority_min"),
2: t("priority_low"),
3: t("priority_default"),
4: t("priority_high"),
5: t("priority_max")
}
let description;
if (minPriority === 1) {
description = t("prefs_notifications_min_priority_description_any");
} else if (minPriority === 5) {
description = t("prefs_notifications_min_priority_description_max");
} else {
description = t("prefs_notifications_min_priority_description_x_or_higher", {
number: minPriority,
name: priorities[minPriority]
});
}
return (
<Pref labelId={labelId} title={t("prefs_notifications_min_priority_title")} description={description}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={minPriority} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem>
<MenuItem value={2}>{t("prefs_notifications_min_priority_low_and_higher")}</MenuItem>
<MenuItem value={3}>{t("prefs_notifications_min_priority_default_and_higher")}</MenuItem>
<MenuItem value={4}>{t("prefs_notifications_min_priority_high_and_higher")}</MenuItem>
<MenuItem value={5}>{t("prefs_notifications_min_priority_max_only")}</MenuItem>
</Select>
</FormControl>
</Pref>
)
};
const DeleteAfter = () => {
const { t } = useTranslation();
const labelId = "prefDeleteAfter";
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
const handleChange = async (ev) => {
await prefs.setDeleteAfter(ev.target.value);
await maybeUpdateAccountSettings({
notification: {
delete_after: ev.target.value
}
});
}
if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0"
return null; // While loading
}
const description = (() => {
switch (deleteAfter) {
case 0: return t("prefs_notifications_delete_after_never_description");
case 10800: return t("prefs_notifications_delete_after_three_hours_description");
case 86400: return t("prefs_notifications_delete_after_one_day_description");
case 604800: return t("prefs_notifications_delete_after_one_week_description");
case 2592000: return t("prefs_notifications_delete_after_one_month_description");
}
})();
return (
<Pref labelId={labelId} title={t("prefs_notifications_delete_after_title")} description={description}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={deleteAfter} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem>
<MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem>
<MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem>
<MenuItem value={604800}>{t("prefs_notifications_delete_after_one_week")}</MenuItem>
<MenuItem value={2592000}>{t("prefs_notifications_delete_after_one_month")}</MenuItem>
</Select>
</FormControl>
</Pref>
)
};
const Users = () => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const users = useLiveQuery(() => userManager.all());
const handleAddClick = () => {
setDialogKey(prev => prev+1);
setDialogOpen(true);
};
const handleDialogCancel = () => {
setDialogOpen(false);
};
const handleDialogSubmit = async (user) => {
setDialogOpen(false);
try {
await userManager.save(user);
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`);
} catch (e) {
console.log(`[Preferences] Error adding user.`, e);
}
};
return (
<Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
<CardContent sx={{ paddingBottom: 1 }}>
<Typography variant="h5" sx={{marginBottom: 2}}>
{t("prefs_users_title")}
</Typography>
<Paragraph>
{t("prefs_users_description")}
{session.exists() && <>{" " + t("prefs_users_description_no_sync")}</>}
</Paragraph>
{users?.length > 0 && <UserTable users={users}/>}
</CardContent>
<CardActions>
<Button onClick={handleAddClick}>{t("prefs_users_add_button")}</Button>
<UserDialog
key={`userAddDialog${dialogKey}`}
open={dialogOpen}
user={null}
users={users}
onCancel={handleDialogCancel}
onSubmit={handleDialogSubmit}
/>
</CardActions>
</Card>
);
};
const UserTable = (props) => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogUser, setDialogUser] = useState(null);
const handleEditClick = (user) => {
setDialogKey(prev => prev+1);
setDialogUser(user);
setDialogOpen(true);
};
const handleDialogCancel = () => {
setDialogOpen(false);
};
const handleDialogSubmit = async (user) => {
setDialogOpen(false);
try {
await userManager.save(user);
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`);
} catch (e) {
console.log(`[Preferences] Error updating user.`, e);
}
};
const handleDeleteClick = async (user) => {
try {
await userManager.delete(user.baseUrl);
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`);
} catch (e) {
console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
}
};
return (
<Table size="small" aria-label={t("prefs_users_table")}>
<TableHead>
<TableRow>
<TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell>
<TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
<TableCell/>
</TableRow>
</TableHead>
<TableBody>
{props.users?.map(user => (
<TableRow
key={user.baseUrl}
sx={{'&:last-child td, &:last-child th': {border: 0}}}
>
<TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{(!session.exists() || user.baseUrl !== config.base_url) &&
<>
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
<EditIcon/>
</IconButton>
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
<CloseIcon/>
</IconButton>
</>
}
{session.exists() && user.baseUrl === config.base_url &&
<Tooltip title={t("prefs_users_table_cannot_delete_or_edit")}>
<span>
<IconButton disabled><EditIcon/></IconButton>
<IconButton disabled><CloseIcon/></IconButton>
</span>
</Tooltip>
}
</TableCell>
</TableRow>
))}
</TableBody>
<UserDialog
key={`userEditDialog${dialogKey}`}
open={dialogOpen}
user={dialogUser}
users={props.users}
onCancel={handleDialogCancel}
onSubmit={handleDialogSubmit}
/>
</Table>
);
};
const UserDialog = (props) => {
const { t } = useTranslation();
const [baseUrl, setBaseUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const editMode = props.user !== null;
const addButtonEnabled = (() => {
if (editMode) {
return username.length > 0 && password.length > 0;
}
const baseUrlValid = validUrl(baseUrl);
const baseUrlExists = props.users?.map(user => user.baseUrl).includes(baseUrl);
return baseUrlValid
&& !baseUrlExists
&& username.length > 0
&& password.length > 0;
})();
const handleSubmit = async () => {
props.onSubmit({
baseUrl: baseUrl,
username: username,
password: password
})
};
useEffect(() => {
if (editMode) {
setBaseUrl(props.user.baseUrl);
setUsername(props.user.username);
setPassword(props.user.password);
}
}, [editMode, props.user]);
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")}</DialogTitle>
<DialogContent>
{!editMode && <TextField
autoFocus
margin="dense"
id="baseUrl"
label={t("prefs_users_dialog_base_url_label")}
aria-label={t("prefs_users_dialog_base_url_label")}
value={baseUrl}
onChange={ev => setBaseUrl(ev.target.value)}
type="url"
fullWidth
variant="standard"
/>}
<TextField
autoFocus={editMode}
margin="dense"
id="username"
label={t("prefs_users_dialog_username_label")}
aria-label={t("prefs_users_dialog_username_label")}
value={username}
onChange={ev => setUsername(ev.target.value)}
type="text"
fullWidth
variant="standard"
/>
<TextField
margin="dense"
id="password"
label={t("prefs_users_dialog_password_label")}
aria-label={t("prefs_users_dialog_password_label")}
type="password"
value={password}
onChange={ev => setPassword(ev.target.value)}
fullWidth
variant="standard"
/>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>{editMode ? t("common_save") : t("common_add")}</Button>
</DialogActions>
</Dialog>
);
};
const Appearance = () => {
const { t } = useTranslation();
return (
<Card sx={{p: 3}} aria-label={t("prefs_appearance_title")}>
<Typography variant="h5" sx={{marginBottom: 2}}>
{t("prefs_appearance_title")}
</Typography>
<PrefGroup>
<Language/>
</PrefGroup>
</Card>
);
};
const Language = () => {
const { t, i18n } = useTranslation();
const labelId = "prefLanguage";
const lang = i18n.resolvedLanguage ?? "en";
// Country flags are displayed using emoji. Emoji rendering is handled by platform fonts.
// Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows.
const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇵🇱", "🇺🇦", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
const showFlags = !navigator.userAgent.includes("Windows");
let title = t("prefs_appearance_language_title");
if (showFlags) {
title += " " + randomFlags.join(" ");
}
const handleChange = async (ev) => {
await i18n.changeLanguage(ev.target.value);
await maybeUpdateAccountSettings({
language: ev.target.value
});
};
// Remember: Flags are not languages. Don't put flags next to the language in the list.
// Languages names from: https://www.omniglot.com/language/names.htm
// Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
return (
<Pref labelId={labelId} title={title}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={lang} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ar">العربية</MenuItem>
<MenuItem value="id">Bahasa Indonesia</MenuItem>
<MenuItem value="bg">Български</MenuItem>
<MenuItem value="cs">Čeština</MenuItem>
<MenuItem value="zh_Hans">中文</MenuItem>
<MenuItem value="da">Dansk</MenuItem>
<MenuItem value="de">Deutsch</MenuItem>
<MenuItem value="es">Español</MenuItem>
<MenuItem value="fr">Français</MenuItem>
<MenuItem value="it">Italiano</MenuItem>
<MenuItem value="hu">Magyar</MenuItem>
<MenuItem value="ko">한국어</MenuItem>
<MenuItem value="ja">日本語</MenuItem>
<MenuItem value="nl">Nederlands</MenuItem>
<MenuItem value="nb_NO">Norsk bokmål</MenuItem>
<MenuItem value="uk">Українська</MenuItem>
<MenuItem value="pt">Português</MenuItem>
<MenuItem value="pt_BR">Português (Brasil)</MenuItem>
<MenuItem value="pl">Polski</MenuItem>
<MenuItem value="ru">Русский</MenuItem>
<MenuItem value="sv">Svenska</MenuItem>
<MenuItem value="tr">Türkçe</MenuItem>
</Select>
</FormControl>
</Pref>
)
};
const Reservations = () => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
if (!config.enable_reservations || !session.exists() || !account) {
return <></>;
}
const reservations = account.reservations || [];
const limitReached = account.role === Role.USER && account.stats.reservations_remaining === 0;
const handleAddClick = () => {
setDialogKey(prev => prev+1);
setDialogOpen(true);
};
return (
<Card sx={{ padding: 1 }} aria-label={t("prefs_reservations_title")}>
<CardContent sx={{ paddingBottom: 1 }}>
<Typography variant="h5" sx={{marginBottom: 2}}>
{t("prefs_reservations_title")}
</Typography>
<Paragraph>
{t("prefs_reservations_description")}
</Paragraph>
{reservations.length > 0 && <ReservationsTable reservations={reservations}/>}
{limitReached && <Alert severity="info">{t("prefs_reservations_limit_reached")}</Alert>}
</CardContent>
<CardActions>
<Button onClick={handleAddClick} disabled={limitReached}>{t("prefs_reservations_add_button")}</Button>
<ReserveAddDialog
key={`reservationAddDialog${dialogKey}`}
open={dialogOpen}
reservations={reservations}
onClose={() => setDialogOpen(false)}
/>
</CardActions>
</Card>
);
};
const ReservationsTable = (props) => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogReservation, setDialogReservation] = useState(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { subscriptions } = useOutletContext();
const localSubscriptions = (subscriptions?.length > 0)
? Object.assign({}, ...subscriptions.filter(s => s.baseUrl === config.base_url).map(s => ({[s.topic]: s})))
: {};
const handleEditClick = (reservation) => {
setDialogKey(prev => prev+1);
setDialogReservation(reservation);
setEditDialogOpen(true);
};
const handleDeleteClick = async (reservation) => {
setDialogKey(prev => prev+1);
setDialogReservation(reservation);
setDeleteDialogOpen(true);
};
const handleSubscribeClick = async (reservation) => {
await subscribeTopic(config.base_url, reservation.topic);
};
return (
<Table size="small" aria-label={t("prefs_reservations_table")}>
<TableHead>
<TableRow>
<TableCell sx={{paddingLeft: 0}}>{t("prefs_reservations_table_topic_header")}</TableCell>
<TableCell>{t("prefs_reservations_table_access_header")}</TableCell>
<TableCell/>
</TableRow>
</TableHead>
<TableBody>
{props.reservations.map(reservation => (
<TableRow
key={reservation.topic}
sx={{'&:last-child td, &:last-child th': { border: 0 }}}
>
<TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_reservations_table_topic_header")}>
{reservation.topic}
</TableCell>
<TableCell aria-label={t("prefs_reservations_table_access_header")}>
{reservation.everyone === Permission.READ_WRITE &&
<>
<PermissionReadWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
{t("prefs_reservations_table_everyone_read_write")}
</>
}
{reservation.everyone === Permission.READ_ONLY &&
<>
<PermissionRead size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
{t("prefs_reservations_table_everyone_read_only")}
</>
}
{reservation.everyone === Permission.WRITE_ONLY &&
<>
<PermissionWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
{t("prefs_reservations_table_everyone_write_only")}
</>
}
{reservation.everyone === Permission.DENY_ALL &&
<>
<PermissionDenyAll size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }}/>
{t("prefs_reservations_table_everyone_deny_all")}
</>
}
</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{!localSubscriptions[reservation.topic] &&
<Tooltip title={t("prefs_reservations_table_click_to_subscribe")}>
<Chip icon={<Info/>} onClick={() => handleSubscribeClick(reservation)} label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined"/>
</Tooltip>
}
<IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
<EditIcon/>
</IconButton>
<IconButton onClick={() => handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}>
<CloseIcon/>
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
<ReserveEditDialog
key={`reservationEditDialog${dialogKey}`}
open={editDialogOpen}
reservation={dialogReservation}
reservations={props.reservations}
onClose={() => setEditDialogOpen(false)}
/>
<ReserveDeleteDialog
key={`reservationDeleteDialog${dialogKey}`}
open={deleteDialogOpen}
topic={dialogReservation?.topic}
onClose={() => setDeleteDialogOpen(false)}
/>
</Table>
);
};
const maybeUpdateAccountSettings = async (payload) => {
if (!session.exists()) {
return;
}
try {
await accountApi.updateSettings(payload);
} catch (e) {
console.log(`[Preferences] Error updating account settings`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
};
export default Preferences;

View File

@@ -0,0 +1,695 @@
import * as React from "react";
import { useContext, useEffect, useState } from "react";
import {
Alert,
CardActions,
CardContent,
Chip,
FormControl,
Select,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tooltip,
useMediaQuery,
Typography,
IconButton,
Container,
TextField,
MenuItem,
Card,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import EditIcon from "@mui/icons-material/Edit";
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 { useOutletContext } from "react-router-dom";
import theme from "./theme";
import userManager from "../app/UserManager";
import { playSound, shuffle, sounds, validUrl } from "../app/utils";
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 { Paragraph } from "./styles";
import prefs from "../app/Prefs";
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors";
import { subscribeTopic } from "./SubscribeDialog";
const maybeUpdateAccountSettings = async (payload) => {
if (!session.exists()) {
return;
}
try {
await accountApi.updateSettings(payload);
} catch (e) {
console.log(`[Preferences] Error updating account settings`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
};
const Preferences = () => (
<Container maxWidth="md" sx={{ marginTop: 3, marginBottom: 3 }}>
<Stack spacing={3}>
<Notifications />
<Reservations />
<Users />
<Appearance />
</Stack>
</Container>
);
const Notifications = () => {
const { t } = useTranslation();
return (
<Card sx={{ p: 3 }} aria-label={t("prefs_notifications_title")}>
<Typography variant="h5" sx={{ marginBottom: 2 }}>
{t("prefs_notifications_title")}
</Typography>
<PrefGroup>
<Sound />
<MinPriority />
<DeleteAfter />
</PrefGroup>
</Card>
);
};
const Sound = () => {
const { t } = useTranslation();
const labelId = "prefSound";
const sound = useLiveQuery(async () => prefs.sound());
const handleChange = async (ev) => {
await prefs.setSound(ev.target.value);
await maybeUpdateAccountSettings({
notification: {
sound: ev.target.value,
},
});
};
if (!sound) {
return null; // While loading
}
let description;
if (sound === "none") {
description = t("prefs_notifications_sound_description_none");
} else {
description = t("prefs_notifications_sound_description_some", {
sound: sounds[sound].label,
});
}
return (
<Pref labelId={labelId} title={t("prefs_notifications_sound_title")} description={description}>
<div style={{ display: "flex", width: "100%" }}>
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
<Select value={sound} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value="none">{t("prefs_notifications_sound_no_sound")}</MenuItem>
{Object.entries(sounds).map((s) => (
<MenuItem key={s[0]} value={s[0]}>
{s[1].label}
</MenuItem>
))}
</Select>
</FormControl>
<IconButton onClick={() => playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}>
<PlayArrowIcon />
</IconButton>
</div>
</Pref>
);
};
const MinPriority = () => {
const { t } = useTranslation();
const labelId = "prefMinPriority";
const minPriority = useLiveQuery(async () => prefs.minPriority());
const handleChange = async (ev) => {
await prefs.setMinPriority(ev.target.value);
await maybeUpdateAccountSettings({
notification: {
min_priority: ev.target.value,
},
});
};
if (!minPriority) {
return null; // While loading
}
const priorities = {
1: t("priority_min"),
2: t("priority_low"),
3: t("priority_default"),
4: t("priority_high"),
5: t("priority_max"),
};
let description;
if (minPriority === 1) {
description = t("prefs_notifications_min_priority_description_any");
} else if (minPriority === 5) {
description = t("prefs_notifications_min_priority_description_max");
} else {
description = t("prefs_notifications_min_priority_description_x_or_higher", {
number: minPriority,
name: priorities[minPriority],
});
}
return (
<Pref labelId={labelId} title={t("prefs_notifications_min_priority_title")} description={description}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={minPriority} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem>
<MenuItem value={2}>{t("prefs_notifications_min_priority_low_and_higher")}</MenuItem>
<MenuItem value={3}>{t("prefs_notifications_min_priority_default_and_higher")}</MenuItem>
<MenuItem value={4}>{t("prefs_notifications_min_priority_high_and_higher")}</MenuItem>
<MenuItem value={5}>{t("prefs_notifications_min_priority_max_only")}</MenuItem>
</Select>
</FormControl>
</Pref>
);
};
const DeleteAfter = () => {
const { t } = useTranslation();
const labelId = "prefDeleteAfter";
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
const handleChange = async (ev) => {
await prefs.setDeleteAfter(ev.target.value);
await maybeUpdateAccountSettings({
notification: {
delete_after: ev.target.value,
},
});
};
if (deleteAfter === null || deleteAfter === undefined) {
// !deleteAfter will not work with "0"
return null; // While loading
}
const description = (() => {
switch (deleteAfter) {
case 0:
return t("prefs_notifications_delete_after_never_description");
case 10800:
return t("prefs_notifications_delete_after_three_hours_description");
case 86400:
return t("prefs_notifications_delete_after_one_day_description");
case 604800:
return t("prefs_notifications_delete_after_one_week_description");
case 2592000:
return t("prefs_notifications_delete_after_one_month_description");
default:
return "";
}
})();
return (
<Pref labelId={labelId} title={t("prefs_notifications_delete_after_title")} description={description}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={deleteAfter} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem>
<MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem>
<MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem>
<MenuItem value={604800}>{t("prefs_notifications_delete_after_one_week")}</MenuItem>
<MenuItem value={2592000}>{t("prefs_notifications_delete_after_one_month")}</MenuItem>
</Select>
</FormControl>
</Pref>
);
};
const Users = () => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const users = useLiveQuery(() => userManager.all());
const handleAddClick = () => {
setDialogKey((prev) => prev + 1);
setDialogOpen(true);
};
const handleDialogCancel = () => {
setDialogOpen(false);
};
const handleDialogSubmit = async (user) => {
setDialogOpen(false);
try {
await userManager.save(user);
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`);
} catch (e) {
console.log(`[Preferences] Error adding user.`, e);
}
};
return (
<Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
<CardContent sx={{ paddingBottom: 1 }}>
<Typography variant="h5" sx={{ marginBottom: 2 }}>
{t("prefs_users_title")}
</Typography>
<Paragraph>
{t("prefs_users_description")}
{session.exists() && <>{` ${t("prefs_users_description_no_sync")}`}</>}
</Paragraph>
{users?.length > 0 && <UserTable users={users} />}
</CardContent>
<CardActions>
<Button onClick={handleAddClick}>{t("prefs_users_add_button")}</Button>
<UserDialog
key={`userAddDialog${dialogKey}`}
open={dialogOpen}
user={null}
users={users}
onCancel={handleDialogCancel}
onSubmit={handleDialogSubmit}
/>
</CardActions>
</Card>
);
};
const UserTable = (props) => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogUser, setDialogUser] = useState(null);
const handleEditClick = (user) => {
setDialogKey((prev) => prev + 1);
setDialogUser(user);
setDialogOpen(true);
};
const handleDialogCancel = () => {
setDialogOpen(false);
};
const handleDialogSubmit = async (user) => {
setDialogOpen(false);
try {
await userManager.save(user);
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`);
} catch (e) {
console.log(`[Preferences] Error updating user.`, e);
}
};
const handleDeleteClick = async (user) => {
try {
await userManager.delete(user.baseUrl);
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`);
} catch (e) {
console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
}
};
return (
<Table size="small" aria-label={t("prefs_users_table")}>
<TableHead>
<TableRow>
<TableCell sx={{ paddingLeft: 0 }}>{t("prefs_users_table_user_header")}</TableCell>
<TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{props.users?.map((user) => (
<TableRow key={user.baseUrl} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
<TableCell component="th" scope="row" sx={{ paddingLeft: 0 }} aria-label={t("prefs_users_table_user_header")}>
{user.username}
</TableCell>
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{(!session.exists() || user.baseUrl !== config.base_url) && (
<>
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
<EditIcon />
</IconButton>
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
<CloseIcon />
</IconButton>
</>
)}
{session.exists() && user.baseUrl === config.base_url && (
<Tooltip title={t("prefs_users_table_cannot_delete_or_edit")}>
<span>
<IconButton disabled>
<EditIcon />
</IconButton>
<IconButton disabled>
<CloseIcon />
</IconButton>
</span>
</Tooltip>
)}
</TableCell>
</TableRow>
))}
</TableBody>
<UserDialog
key={`userEditDialog${dialogKey}`}
open={dialogOpen}
user={dialogUser}
users={props.users}
onCancel={handleDialogCancel}
onSubmit={handleDialogSubmit}
/>
</Table>
);
};
const UserDialog = (props) => {
const { t } = useTranslation();
const [baseUrl, setBaseUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const editMode = props.user !== null;
const addButtonEnabled = (() => {
if (editMode) {
return username.length > 0 && password.length > 0;
}
const baseUrlValid = validUrl(baseUrl);
const baseUrlExists = props.users?.map((user) => user.baseUrl).includes(baseUrl);
return baseUrlValid && !baseUrlExists && username.length > 0 && password.length > 0;
})();
const handleSubmit = async () => {
props.onSubmit({
baseUrl,
username,
password,
});
};
useEffect(() => {
if (editMode) {
setBaseUrl(props.user.baseUrl);
setUsername(props.user.username);
setPassword(props.user.password);
}
}, [editMode, props.user]);
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")}</DialogTitle>
<DialogContent>
{!editMode && (
<TextField
autoFocus
margin="dense"
id="baseUrl"
label={t("prefs_users_dialog_base_url_label")}
aria-label={t("prefs_users_dialog_base_url_label")}
value={baseUrl}
onChange={(ev) => setBaseUrl(ev.target.value)}
type="url"
fullWidth
variant="standard"
/>
)}
<TextField
autoFocus={editMode}
margin="dense"
id="username"
label={t("prefs_users_dialog_username_label")}
aria-label={t("prefs_users_dialog_username_label")}
value={username}
onChange={(ev) => setUsername(ev.target.value)}
type="text"
fullWidth
variant="standard"
/>
<TextField
margin="dense"
id="password"
label={t("prefs_users_dialog_password_label")}
aria-label={t("prefs_users_dialog_password_label")}
type="password"
value={password}
onChange={(ev) => setPassword(ev.target.value)}
fullWidth
variant="standard"
/>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>
{editMode ? t("common_save") : t("common_add")}
</Button>
</DialogActions>
</Dialog>
);
};
const Appearance = () => {
const { t } = useTranslation();
return (
<Card sx={{ p: 3 }} aria-label={t("prefs_appearance_title")}>
<Typography variant="h5" sx={{ marginBottom: 2 }}>
{t("prefs_appearance_title")}
</Typography>
<PrefGroup>
<Language />
</PrefGroup>
</Card>
);
};
const Language = () => {
const { t, i18n } = useTranslation();
const labelId = "prefLanguage";
const lang = i18n.resolvedLanguage ?? "en";
// Country flags are displayed using emoji. Emoji rendering is handled by platform fonts.
// Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows.
const randomFlags = shuffle([
"🇬🇧",
"🇺🇸",
"🇪🇸",
"🇫🇷",
"🇧🇬",
"🇨🇿",
"🇩🇪",
"🇵🇱",
"🇺🇦",
"🇨🇳",
"🇮🇹",
"🇭🇺",
"🇧🇷",
"🇳🇱",
"🇮🇩",
"🇯🇵",
"🇷🇺",
"🇹🇷",
]).slice(0, 3);
const showFlags = !navigator.userAgent.includes("Windows");
let title = t("prefs_appearance_language_title");
if (showFlags) {
title += ` ${randomFlags.join(" ")}`;
}
const handleChange = async (ev) => {
await i18n.changeLanguage(ev.target.value);
await maybeUpdateAccountSettings({
language: ev.target.value,
});
};
// Remember: Flags are not languages. Don't put flags next to the language in the list.
// Languages names from: https://www.omniglot.com/language/names.htm
// Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
return (
<Pref labelId={labelId} title={title}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={lang} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ar">العربية</MenuItem>
<MenuItem value="id">Bahasa Indonesia</MenuItem>
<MenuItem value="bg">Български</MenuItem>
<MenuItem value="cs">Čeština</MenuItem>
<MenuItem value="zh_Hans">中文</MenuItem>
<MenuItem value="da">Dansk</MenuItem>
<MenuItem value="de">Deutsch</MenuItem>
<MenuItem value="es">Español</MenuItem>
<MenuItem value="fr">Français</MenuItem>
<MenuItem value="it">Italiano</MenuItem>
<MenuItem value="hu">Magyar</MenuItem>
<MenuItem value="ko">한국어</MenuItem>
<MenuItem value="ja">日本語</MenuItem>
<MenuItem value="nl">Nederlands</MenuItem>
<MenuItem value="nb_NO">Norsk bokmål</MenuItem>
<MenuItem value="uk">Українська</MenuItem>
<MenuItem value="pt">Português</MenuItem>
<MenuItem value="pt_BR">Português (Brasil)</MenuItem>
<MenuItem value="pl">Polski</MenuItem>
<MenuItem value="ru">Русский</MenuItem>
<MenuItem value="sv">Svenska</MenuItem>
<MenuItem value="tr">Türkçe</MenuItem>
</Select>
</FormControl>
</Pref>
);
};
const Reservations = () => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
if (!config.enable_reservations || !session.exists() || !account) {
return <></>;
}
const reservations = account.reservations || [];
const limitReached = account.role === Role.USER && account.stats.reservations_remaining === 0;
const handleAddClick = () => {
setDialogKey((prev) => prev + 1);
setDialogOpen(true);
};
return (
<Card sx={{ padding: 1 }} aria-label={t("prefs_reservations_title")}>
<CardContent sx={{ paddingBottom: 1 }}>
<Typography variant="h5" sx={{ marginBottom: 2 }}>
{t("prefs_reservations_title")}
</Typography>
<Paragraph>{t("prefs_reservations_description")}</Paragraph>
{reservations.length > 0 && <ReservationsTable reservations={reservations} />}
{limitReached && <Alert severity="info">{t("prefs_reservations_limit_reached")}</Alert>}
</CardContent>
<CardActions>
<Button onClick={handleAddClick} disabled={limitReached}>
{t("prefs_reservations_add_button")}
</Button>
<ReserveAddDialog
key={`reservationAddDialog${dialogKey}`}
open={dialogOpen}
reservations={reservations}
onClose={() => setDialogOpen(false)}
/>
</CardActions>
</Card>
);
};
const ReservationsTable = (props) => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogReservation, setDialogReservation] = useState(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { subscriptions } = useOutletContext();
const localSubscriptions =
subscriptions?.length > 0
? Object.assign({}, ...subscriptions.filter((s) => s.baseUrl === config.base_url).map((s) => ({ [s.topic]: s })))
: {};
const handleEditClick = (reservation) => {
setDialogKey((prev) => prev + 1);
setDialogReservation(reservation);
setEditDialogOpen(true);
};
const handleDeleteClick = async (reservation) => {
setDialogKey((prev) => prev + 1);
setDialogReservation(reservation);
setDeleteDialogOpen(true);
};
const handleSubscribeClick = async (reservation) => {
await subscribeTopic(config.base_url, reservation.topic);
};
return (
<Table size="small" aria-label={t("prefs_reservations_table")}>
<TableHead>
<TableRow>
<TableCell sx={{ paddingLeft: 0 }}>{t("prefs_reservations_table_topic_header")}</TableCell>
<TableCell>{t("prefs_reservations_table_access_header")}</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{props.reservations.map((reservation) => (
<TableRow key={reservation.topic} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
<TableCell component="th" scope="row" sx={{ paddingLeft: 0 }} aria-label={t("prefs_reservations_table_topic_header")}>
{reservation.topic}
</TableCell>
<TableCell aria-label={t("prefs_reservations_table_access_header")}>
{reservation.everyone === Permission.READ_WRITE && (
<>
<PermissionReadWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }} />
{t("prefs_reservations_table_everyone_read_write")}
</>
)}
{reservation.everyone === Permission.READ_ONLY && (
<>
<PermissionRead size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }} />
{t("prefs_reservations_table_everyone_read_only")}
</>
)}
{reservation.everyone === Permission.WRITE_ONLY && (
<>
<PermissionWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }} />
{t("prefs_reservations_table_everyone_write_only")}
</>
)}
{reservation.everyone === Permission.DENY_ALL && (
<>
<PermissionDenyAll size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }} />
{t("prefs_reservations_table_everyone_deny_all")}
</>
)}
</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{!localSubscriptions[reservation.topic] && (
<Tooltip title={t("prefs_reservations_table_click_to_subscribe")}>
<Chip
icon={<Info />}
onClick={() => handleSubscribeClick(reservation)}
label={t("prefs_reservations_table_not_subscribed")}
color="primary"
variant="outlined"
/>
</Tooltip>
)}
<IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
<EditIcon />
</IconButton>
<IconButton onClick={() => handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}>
<CloseIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
<ReserveEditDialog
key={`reservationEditDialog${dialogKey}`}
open={editDialogOpen}
reservation={dialogReservation}
reservations={props.reservations}
onClose={() => setEditDialogOpen(false)}
/>
<ReserveDeleteDialog
key={`reservationDeleteDialog${dialogKey}`}
open={deleteDialogOpen}
topic={dialogReservation?.topic}
onClose={() => setDeleteDialogOpen(false)}
/>
</Table>
);
};
export default Preferences;

View File

@@ -1,789 +0,0 @@
import * as React from 'react';
import {useContext, useEffect, useRef, useState} from 'react';
import theme from "./theme";
import {
Checkbox,
Chip,
FormControl,
FormControlLabel,
InputLabel,
Link,
Select,
Tooltip,
useMediaQuery
} from "@mui/material";
import TextField from "@mui/material/TextField";
import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
import priority3 from "../img/priority-3.svg";
import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
import {Close} from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem";
import {formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils";
import Box from "@mui/material/Box";
import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter";
import api from "../app/Api";
import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker";
import {Trans, useTranslation} from "react-i18next";
import session from "../app/Session";
import routes from "./routes";
import accountApi from "../app/AccountApi";
import {UnauthorizedError} from "../app/errors";
import {AccountContext} from "./App";
const PublishDialog = (props) => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const [baseUrl, setBaseUrl] = useState("");
const [topic, setTopic] = useState("");
const [message, setMessage] = useState("");
const [messageFocused, setMessageFocused] = useState(true);
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [priority, setPriority] = useState(3);
const [clickUrl, setClickUrl] = useState("");
const [attachUrl, setAttachUrl] = useState("");
const [attachFile, setAttachFile] = useState(null);
const [filename, setFilename] = useState("");
const [filenameEdited, setFilenameEdited] = useState(false);
const [email, setEmail] = useState("");
const [call, setCall] = useState("");
const [delay, setDelay] = useState("");
const [publishAnother, setPublishAnother] = useState(false);
const [showTopicUrl, setShowTopicUrl] = useState("");
const [showClickUrl, setShowClickUrl] = useState(false);
const [showAttachUrl, setShowAttachUrl] = useState(false);
const [showEmail, setShowEmail] = useState(false);
const [showCall, setShowCall] = useState(false);
const [showDelay, setShowDelay] = useState(false);
const showAttachFile = !!attachFile && !showAttachUrl;
const attachFileInput = useRef();
const [attachFileError, setAttachFileError] = useState("");
const [activeRequest, setActiveRequest] = useState(null);
const [status, setStatus] = useState("");
const disabled = !!activeRequest;
const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null);
const [dropZone, setDropZone] = useState(false);
const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
const open = !!props.openMode;
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
useEffect(() => {
window.addEventListener('dragenter', () => {
props.onDragEnter();
setDropZone(true);
});
}, []);
useEffect(() => {
setBaseUrl(props.baseUrl);
setTopic(props.topic);
setShowTopicUrl(!props.baseUrl || !props.topic);
setMessageFocused(!!props.topic); // Focus message only if topic is set
}, [props.baseUrl, props.topic]);
useEffect(() => {
const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError;
setSendButtonEnabled(valid);
}, [baseUrl, topic, attachFileError]);
useEffect(() => {
setMessage(props.message);
}, [props.message]);
const updateBaseUrl = (newVal) => {
if (validUrl(newVal)) {
setBaseUrl(newVal.replace(/\/$/, '')); // strip traililng slash after https?://
} else {
setBaseUrl(newVal);
}
};
const handleSubmit = async () => {
const url = new URL(topicUrl(baseUrl, topic));
if (title.trim()) {
url.searchParams.append("title", title.trim());
}
if (tags.trim()) {
url.searchParams.append("tags", tags.trim());
}
if (priority && priority !== 3) {
url.searchParams.append("priority", priority.toString());
}
if (clickUrl.trim()) {
url.searchParams.append("click", clickUrl.trim());
}
if (attachUrl.trim()) {
url.searchParams.append("attach", attachUrl.trim());
}
if (filename.trim()) {
url.searchParams.append("filename", filename.trim());
}
if (email.trim()) {
url.searchParams.append("email", email.trim());
}
if (call.trim()) {
url.searchParams.append("call", call.trim());
}
if (delay.trim()) {
url.searchParams.append("delay", delay.trim());
}
if (attachFile && message.trim()) {
url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
}
const body = (attachFile) ? attachFile : message;
try {
const user = await userManager.get(baseUrl);
const headers = maybeWithAuth({}, user);
const progressFn = (ev) => {
if (ev.loaded > 0 && ev.total > 0) {
setStatus(t("publish_dialog_progress_uploading_detail", {
loaded: formatBytes(ev.loaded),
total: formatBytes(ev.total),
percent: Math.round(ev.loaded * 100.0 / ev.total)
}));
} else {
setStatus(t("publish_dialog_progress_uploading"));
}
};
const request = api.publishXHR(url, body, headers, progressFn);
setActiveRequest(request);
await request;
if (!publishAnother) {
props.onClose();
} else {
setStatus(t("publish_dialog_message_published"));
setActiveRequest(null);
}
} catch (e) {
setStatus(<Typography sx={{color: 'error.main', maxWidth: "400px"}}>{e}</Typography>);
setActiveRequest(null);
}
};
const checkAttachmentLimits = async (file) => {
try {
const account = await accountApi.get();
const fileSizeLimit = account.limits.attachment_file_size ?? 0;
const remainingBytes = account.stats.attachment_total_size_remaining;
const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;
const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
if (fileSizeLimitReached && quotaReached) {
return setAttachFileError(t("publish_dialog_attachment_limits_file_and_quota_reached", {
fileSizeLimit: formatBytes(fileSizeLimit),
remainingBytes: formatBytes(remainingBytes)
}));
} else if (fileSizeLimitReached) {
return setAttachFileError(t("publish_dialog_attachment_limits_file_reached", { fileSizeLimit: formatBytes(fileSizeLimit) }));
} else if (quotaReached) {
return setAttachFileError(t("publish_dialog_attachment_limits_quota_reached", { remainingBytes: formatBytes(remainingBytes) }));
}
setAttachFileError("");
} catch (e) {
console.log(`[PublishDialog] Retrieving attachment limits failed`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setAttachFileError(""); // Reset error (rely on server-side checking)
}
}
};
const handleAttachFileClick = () => {
attachFileInput.current.click();
};
const handleAttachFileChanged = async (ev) => {
await updateAttachFile(ev.target.files[0]);
};
const handleAttachFileDrop = async (ev) => {
ev.preventDefault();
setDropZone(false);
await updateAttachFile(ev.dataTransfer.files[0]);
};
const updateAttachFile = async (file) => {
setAttachFile(file);
setFilename(file.name);
props.onResetOpenMode();
await checkAttachmentLimits(file);
};
const handleAttachFileDragLeave = () => {
setDropZone(false);
if (props.openMode === PublishDialog.OPEN_MODE_DRAG) {
props.onClose(); // Only close dialog if it was not open before dragging file in
}
};
const handleEmojiClick = (ev) => {
setEmojiPickerAnchorEl(ev.currentTarget);
};
const handleEmojiPick = (emoji) => {
setTags(tags => (tags.trim()) ? `${tags.trim()}, ${emoji}` : emoji);
};
const handleEmojiClose = () => {
setEmojiPickerAnchorEl(null);
};
const priorities = {
1: { label: t("publish_dialog_priority_min"), file: priority1 },
2: { label: t("publish_dialog_priority_low"), file: priority2 },
3: { label: t("publish_dialog_priority_default"), file: priority3 },
4: { label: t("publish_dialog_priority_high"), file: priority4 },
5: { label: t("publish_dialog_priority_max"), file: priority5 }
};
return (
<>
{dropZone && <DropArea
onDrop={handleAttachFileDrop}
onDragLeave={handleAttachFileDragLeave}/>
}
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{(baseUrl && topic) ? t("publish_dialog_title_topic", { topic: topicShortUrl(baseUrl, topic) }) : t("publish_dialog_title_no_topic")}</DialogTitle>
<DialogContent>
{dropZone && <DropBox/>}
{showTopicUrl &&
<ClosableRow closable={!!props.baseUrl && !!props.topic} disabled={disabled} closeLabel={t("publish_dialog_topic_reset")} onClose={() => {
setBaseUrl(props.baseUrl);
setTopic(props.topic);
setShowTopicUrl(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_base_url_label")}
placeholder={t("publish_dialog_base_url_placeholder")}
value={baseUrl}
onChange={ev => updateBaseUrl(ev.target.value)}
disabled={disabled}
type="url"
variant="standard"
sx={{flexGrow: 1, marginRight: 1}}
inputProps={{
"aria-label": t("publish_dialog_base_url_label")
}}
/>
<TextField
margin="dense"
label={t("publish_dialog_topic_label")}
placeholder={t("publish_dialog_topic_placeholder")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
autoFocus={!messageFocused}
sx={{flexGrow: 1}}
inputProps={{
"aria-label": t("publish_dialog_topic_label")
}}
/>
</ClosableRow>
}
<TextField
margin="dense"
label={t("publish_dialog_title_label")}
placeholder={t("publish_dialog_title_placeholder")}
value={title}
onChange={ev => setTitle(ev.target.value)}
disabled={disabled}
type="text"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("publish_dialog_title_label")
}}
/>
<TextField
margin="dense"
label={t("publish_dialog_message_label")}
placeholder={t("publish_dialog_message_placeholder")}
value={message}
onChange={ev => setMessage(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
rows={5}
autoFocus={messageFocused}
fullWidth
multiline
inputProps={{
"aria-label": t("publish_dialog_message_label")
}}
/>
<div style={{display: 'flex'}}>
<EmojiPicker
anchorEl={emojiPickerAnchorEl}
onEmojiPick={handleEmojiPick}
onClose={handleEmojiClose}
/>
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
<InsertEmoticonIcon/>
</DialogIconButton>
<TextField
margin="dense"
label={t("publish_dialog_tags_label")}
placeholder={t("publish_dialog_tags_placeholder")}
value={tags}
onChange={ev => setTags(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
sx={{flexGrow: 1, marginRight: 1}}
inputProps={{
"aria-label": t("publish_dialog_tags_label")
}}
/>
<FormControl
variant="standard"
margin="dense"
sx={{minWidth: 170, maxWidth: 300, flexGrow: 1}}
>
<InputLabel/>
<Select
label={t("publish_dialog_priority_label")}
margin="dense"
value={priority}
onChange={(ev) => setPriority(ev.target.value)}
disabled={disabled}
inputProps={{
"aria-label": t("publish_dialog_priority_label")
}}
>
{[5,4,3,2,1].map(priority =>
<MenuItem key={`priorityMenuItem${priority}`} value={priority} aria-label={t("notifications_priority_x", { priority: priority })}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<img src={priorities[priority].file} style={{marginRight: "8px"}} alt={t("notifications_priority_x", { priority: priority })}/>
<div>{priorities[priority].label}</div>
</div>
</MenuItem>
)}
</Select>
</FormControl>
</div>
{showClickUrl &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_click_reset")} onClose={() => {
setClickUrl("");
setShowClickUrl(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_click_label")}
placeholder={t("publish_dialog_click_placeholder")}
value={clickUrl}
onChange={ev => setClickUrl(ev.target.value)}
disabled={disabled}
type="url"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("publish_dialog_click_label")
}}
/>
</ClosableRow>
}
{showEmail &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_email_reset")} onClose={() => {
setEmail("");
setShowEmail(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_email_label")}
placeholder={t("publish_dialog_email_placeholder")}
value={email}
onChange={ev => setEmail(ev.target.value)}
disabled={disabled}
type="email"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_email_label")
}}
/>
</ClosableRow>
}
{showCall &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_call_reset")} onClose={() => {
setCall("");
setShowCall(false);
}}>
<FormControl
fullWidth
variant="standard"
margin="dense"
>
<InputLabel/>
<Select
label={t("publish_dialog_call_label")}
margin="dense"
value={call}
onChange={(ev) => setCall(ev.target.value)}
disabled={disabled}
inputProps={{
"aria-label": t("publish_dialog_call_label")
}}
>
{account?.phone_numbers?.map((phoneNumber, i) =>
<MenuItem key={`phoneNumberMenuItem${i}`} value={phoneNumber} aria-label={phoneNumber}>
{t("publish_dialog_call_item", { number: phoneNumber })}
</MenuItem>
)}
</Select>
</FormControl>
</ClosableRow>
}
{showAttachUrl &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
setAttachUrl("");
setFilename("");
setFilenameEdited(false);
setShowAttachUrl(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_attach_label")}
placeholder={t("publish_dialog_attach_placeholder")}
value={attachUrl}
onChange={ev => {
const url = ev.target.value;
setAttachUrl(url);
if (!filenameEdited) {
try {
const u = new URL(url);
const parts = u.pathname.split("/");
if (parts.length > 0) {
setFilename(parts[parts.length-1]);
}
} catch (e) {
// Do nothing
}
}
}}
disabled={disabled}
type="url"
variant="standard"
sx={{flexGrow: 5, marginRight: 1}}
inputProps={{
"aria-label": t("publish_dialog_attach_label")
}}
/>
<TextField
margin="dense"
label={t("publish_dialog_filename_label")}
placeholder={t("publish_dialog_filename_placeholder")}
value={filename}
onChange={ev => {
setFilename(ev.target.value);
setFilenameEdited(true);
}}
disabled={disabled}
type="text"
variant="standard"
sx={{flexGrow: 1}}
inputProps={{
"aria-label": t("publish_dialog_filename_label")
}}
/>
</ClosableRow>
}
<input
type="file"
ref={attachFileInput}
onChange={handleAttachFileChanged}
style={{ display: 'none' }}
aria-hidden={true}
/>
{showAttachFile && <AttachmentBox
file={attachFile}
filename={filename}
disabled={disabled}
error={attachFileError}
onChangeFilename={(f) => setFilename(f)}
onClose={() => {
setAttachFile(null);
setAttachFileError("");
setFilename("");
}}
/>}
{showDelay &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_delay_reset")} onClose={() => {
setDelay("");
setShowDelay(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_delay_label")}
placeholder={t("publish_dialog_delay_placeholder", {
unixTimestamp: "1649029748",
relativeTime: "30m",
naturalLanguage: "tomorrow, 9am"
})}
value={delay}
onChange={ev => setDelay(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_delay_label")
}}
/>
</ClosableRow>
}
<Typography variant="body1" sx={{marginTop: 2, marginBottom: 1}}>
{t("publish_dialog_other_features")}
</Typography>
<div>
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{account?.phone_numbers?.length > 0 && !showCall && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_call_label")} aria-label={t("publish_dialog_chip_call_label")} onClick={() => { setShowCall(true); setCall(account.phone_numbers[0]); }} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showTopicUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_topic_label")} aria-label={t("publish_dialog_chip_topic_label")} onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{account && !account?.phone_numbers && <Tooltip title={t("publish_dialog_chip_call_no_verified_numbers_tooltip")}><span><Chip clickable disabled label={t("publish_dialog_chip_call_label")} aria-label={t("publish_dialog_chip_call_label")} sx={{marginRight: 1, marginBottom: 1}}/></span></Tooltip>}
</div>
<Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}>
<Trans
i18nKey="publish_dialog_details_examples_description"
components={{
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/>
}}
/>
</Typography>
</DialogContent>
<DialogFooter status={status}>
{activeRequest && <Button onClick={() => activeRequest.abort()}>{t("publish_dialog_button_cancel_sending")}</Button>}
{!activeRequest &&
<>
<FormControlLabel
label={t("publish_dialog_checkbox_publish_another")}
sx={{marginRight: 2}}
control={
<Checkbox
size="small"
checked={publishAnother}
onChange={(ev) => setPublishAnother(ev.target.checked)}
inputProps={{
"aria-label": t("publish_dialog_checkbox_publish_another")
}} />
} />
<Button onClick={props.onClose}>{t("publish_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>{t("publish_dialog_button_send")}</Button>
</>
}
</DialogFooter>
</Dialog>
</>
);
};
const Row = (props) => {
return (
<div style={{display: 'flex'}} role="row">
{props.children}
</div>
);
};
const ClosableRow = (props) => {
const closable = (props.hasOwnProperty("closable")) ? props.closable : true;
return (
<Row>
{props.children}
{closable &&
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={props.closeLabel}>
<Close/>
</DialogIconButton>
}
</Row>
);
};
const DialogIconButton = (props) => {
const sx = props.sx || {};
return (
<IconButton
color="inherit"
size="large"
edge="start"
sx={{height: "45px", marginTop: "17px", ...sx}}
onClick={props.onClick}
disabled={props.disabled}
aria-label={props["aria-label"]}
>
{props.children}
</IconButton>
);
};
const AttachmentBox = (props) => {
const { t } = useTranslation();
const file = props.file;
return (
<>
<Typography variant="body1" sx={{marginTop: 2}}>
{t("publish_dialog_attached_file_title")}
</Typography>
<Box sx={{
display: 'flex',
alignItems: 'center',
padding: 0.5,
borderRadius: '4px',
}}>
<AttachmentIcon type={file.type}/>
<Box sx={{ marginLeft: 1, textAlign: 'left' }}>
<ExpandingTextField
minWidth={140}
variant="body2"
placeholder={t("publish_dialog_attached_file_filename_placeholder")}
value={props.filename}
onChange={(ev) => props.onChangeFilename(ev.target.value)}
disabled={props.disabled}
/>
<br/>
<Typography variant="body2" sx={{ color: 'text.primary' }}>
{formatBytes(file.size)}
{props.error &&
<Typography component="span" sx={{ color: 'error.main' }} aria-live="polite">
{" "}({props.error})
</Typography>
}
</Typography>
</Box>
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={t("publish_dialog_attached_file_remove")}>
<Close/>
</DialogIconButton>
</Box>
</>
);
};
const ExpandingTextField = (props) => {
const invisibleFieldRef = useRef();
const [textWidth, setTextWidth] = useState(props.minWidth);
const determineTextWidth = () => {
const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect();
if (!boundingRect) {
return props.minWidth;
}
return (boundingRect.width >= props.minWidth) ? Math.round(boundingRect.width) : props.minWidth;
};
useEffect(() => {
setTextWidth(determineTextWidth() + 5);
}, [props.value]);
return (
<>
<Typography
ref={invisibleFieldRef}
component="span"
variant={props.variant}
aria-hidden={true}
sx={{position: "absolute", left: "-200%"}}
>
{props.value}
</Typography>
<TextField
margin="dense"
placeholder={props.placeholder}
value={props.value}
onChange={props.onChange}
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
}}
disabled={props.disabled}
/>
</>
)
};
const DropArea = (props) => {
const allowDrag = (ev) => {
// This is where we could disallow certain files to be dragged in.
// For now we allow all files.
ev.dataTransfer.dropEffect = 'copy';
ev.preventDefault();
};
return (
<Box
sx={{
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
zIndex: 10002,
}}
onDrop={props.onDrop}
onDragEnter={allowDrag}
onDragOver={allowDrag}
onDragLeave={props.onDragLeave}
/>
);
};
const DropBox = () => {
const { t } = useTranslation();
return (
<Box sx={{
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
zIndex: 10000,
backgroundColor: "#ffffffbb"
}}>
<Box
sx={{
position: 'absolute',
border: '3px dashed #ccc',
borderRadius: '5px',
left: "40px",
top: "40px",
right: "40px",
bottom: "40px",
zIndex: 10001,
display: 'flex',
justifyContent: "center",
alignItems: "center",
}}
>
<Typography variant="h5">{t("publish_dialog_drop_file_here")}</Typography>
</Box>
</Box>
);
}
PublishDialog.OPEN_MODE_DEFAULT = "default";
PublishDialog.OPEN_MODE_DRAG = "drag";
export default PublishDialog;

View File

@@ -0,0 +1,913 @@
import * as React from "react";
import { useContext, useEffect, useRef, useState } from "react";
import {
Checkbox,
Chip,
FormControl,
FormControlLabel,
InputLabel,
Link,
Select,
Tooltip,
useMediaQuery,
TextField,
Dialog,
DialogTitle,
DialogContent,
Button,
Typography,
IconButton,
MenuItem,
Box,
} from "@mui/material";
import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
import { Close } from "@mui/icons-material";
import { Trans, useTranslation } from "react-i18next";
import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
import priority3 from "../img/priority-3.svg";
import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils";
import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter";
import api from "../app/Api";
import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker";
import theme from "./theme";
import session from "../app/Session";
import routes from "./routes";
import accountApi from "../app/AccountApi";
import { UnauthorizedError } from "../app/errors";
import { AccountContext } from "./App";
const PublishDialog = (props) => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const [baseUrl, setBaseUrl] = useState("");
const [topic, setTopic] = useState("");
const [message, setMessage] = useState("");
const [messageFocused, setMessageFocused] = useState(true);
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [priority, setPriority] = useState(3);
const [clickUrl, setClickUrl] = useState("");
const [attachUrl, setAttachUrl] = useState("");
const [attachFile, setAttachFile] = useState(null);
const [filename, setFilename] = useState("");
const [filenameEdited, setFilenameEdited] = useState(false);
const [email, setEmail] = useState("");
const [call, setCall] = useState("");
const [delay, setDelay] = useState("");
const [publishAnother, setPublishAnother] = useState(false);
const [showTopicUrl, setShowTopicUrl] = useState("");
const [showClickUrl, setShowClickUrl] = useState(false);
const [showAttachUrl, setShowAttachUrl] = useState(false);
const [showEmail, setShowEmail] = useState(false);
const [showCall, setShowCall] = useState(false);
const [showDelay, setShowDelay] = useState(false);
const showAttachFile = !!attachFile && !showAttachUrl;
const attachFileInput = useRef();
const [attachFileError, setAttachFileError] = useState("");
const [activeRequest, setActiveRequest] = useState(null);
const [status, setStatus] = useState("");
const disabled = !!activeRequest;
const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null);
const [dropZone, setDropZone] = useState(false);
const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
const open = !!props.openMode;
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
useEffect(() => {
window.addEventListener("dragenter", () => {
props.onDragEnter();
setDropZone(true);
});
}, []);
useEffect(() => {
setBaseUrl(props.baseUrl);
setTopic(props.topic);
setShowTopicUrl(!props.baseUrl || !props.topic);
setMessageFocused(!!props.topic); // Focus message only if topic is set
}, [props.baseUrl, props.topic]);
useEffect(() => {
const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError;
setSendButtonEnabled(valid);
}, [baseUrl, topic, attachFileError]);
useEffect(() => {
setMessage(props.message);
}, [props.message]);
const updateBaseUrl = (newVal) => {
if (validUrl(newVal)) {
setBaseUrl(newVal.replace(/\/$/, "")); // strip traililng slash after https?://
} else {
setBaseUrl(newVal);
}
};
const handleSubmit = async () => {
const url = new URL(topicUrl(baseUrl, topic));
if (title.trim()) {
url.searchParams.append("title", title.trim());
}
if (tags.trim()) {
url.searchParams.append("tags", tags.trim());
}
if (priority && priority !== 3) {
url.searchParams.append("priority", priority.toString());
}
if (clickUrl.trim()) {
url.searchParams.append("click", clickUrl.trim());
}
if (attachUrl.trim()) {
url.searchParams.append("attach", attachUrl.trim());
}
if (filename.trim()) {
url.searchParams.append("filename", filename.trim());
}
if (email.trim()) {
url.searchParams.append("email", email.trim());
}
if (call.trim()) {
url.searchParams.append("call", call.trim());
}
if (delay.trim()) {
url.searchParams.append("delay", delay.trim());
}
if (attachFile && message.trim()) {
url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
}
const body = attachFile || message;
try {
const user = await userManager.get(baseUrl);
const headers = maybeWithAuth({}, user);
const progressFn = (ev) => {
if (ev.loaded > 0 && ev.total > 0) {
setStatus(
t("publish_dialog_progress_uploading_detail", {
loaded: formatBytes(ev.loaded),
total: formatBytes(ev.total),
percent: Math.round((ev.loaded * 100.0) / ev.total),
})
);
} else {
setStatus(t("publish_dialog_progress_uploading"));
}
};
const request = api.publishXHR(url, body, headers, progressFn);
setActiveRequest(request);
await request;
if (!publishAnother) {
props.onClose();
} else {
setStatus(t("publish_dialog_message_published"));
setActiveRequest(null);
}
} catch (e) {
setStatus(<Typography sx={{ color: "error.main", maxWidth: "400px" }}>{e}</Typography>);
setActiveRequest(null);
}
};
const checkAttachmentLimits = async (file) => {
try {
const apiAccount = await accountApi.get();
const fileSizeLimit = apiAccount.limits.attachment_file_size ?? 0;
const remainingBytes = apiAccount.stats.attachment_total_size_remaining;
const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;
const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
if (fileSizeLimitReached && quotaReached) {
setAttachFileError(
t("publish_dialog_attachment_limits_file_and_quota_reached", {
fileSizeLimit: formatBytes(fileSizeLimit),
remainingBytes: formatBytes(remainingBytes),
})
);
} else if (fileSizeLimitReached) {
setAttachFileError(
t("publish_dialog_attachment_limits_file_reached", {
fileSizeLimit: formatBytes(fileSizeLimit),
})
);
} else if (quotaReached) {
setAttachFileError(
t("publish_dialog_attachment_limits_quota_reached", {
remainingBytes: formatBytes(remainingBytes),
})
);
} else {
setAttachFileError("");
}
} catch (e) {
console.log(`[PublishDialog] Retrieving attachment limits failed`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setAttachFileError(""); // Reset error (rely on server-side checking)
}
}
};
const handleAttachFileClick = () => {
attachFileInput.current.click();
};
const updateAttachFile = async (file) => {
setAttachFile(file);
setFilename(file.name);
props.onResetOpenMode();
await checkAttachmentLimits(file);
};
const handleAttachFileChanged = async (ev) => {
await updateAttachFile(ev.target.files[0]);
};
const handleAttachFileDrop = async (ev) => {
ev.preventDefault();
setDropZone(false);
await updateAttachFile(ev.dataTransfer.files[0]);
};
const handleAttachFileDragLeave = () => {
setDropZone(false);
if (props.openMode === PublishDialog.OPEN_MODE_DRAG) {
props.onClose(); // Only close dialog if it was not open before dragging file in
}
};
const handleEmojiClick = (ev) => {
setEmojiPickerAnchorEl(ev.currentTarget);
};
const handleEmojiPick = (emoji) => {
setTags((prevTags) => (prevTags.trim() ? `${prevTags.trim()}, ${emoji}` : emoji));
};
const handleEmojiClose = () => {
setEmojiPickerAnchorEl(null);
};
const priorities = {
1: { label: t("publish_dialog_priority_min"), file: priority1 },
2: { label: t("publish_dialog_priority_low"), file: priority2 },
3: { label: t("publish_dialog_priority_default"), file: priority3 },
4: { label: t("publish_dialog_priority_high"), file: priority4 },
5: { label: t("publish_dialog_priority_max"), file: priority5 },
};
return (
<>
{dropZone && <DropArea onDrop={handleAttachFileDrop} onDragLeave={handleAttachFileDragLeave} />}
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>
{baseUrl && topic
? t("publish_dialog_title_topic", {
topic: topicShortUrl(baseUrl, topic),
})
: t("publish_dialog_title_no_topic")}
</DialogTitle>
<DialogContent>
{dropZone && <DropBox />}
{showTopicUrl && (
<ClosableRow
closable={!!props.baseUrl && !!props.topic}
disabled={disabled}
closeLabel={t("publish_dialog_topic_reset")}
onClose={() => {
setBaseUrl(props.baseUrl);
setTopic(props.topic);
setShowTopicUrl(false);
}}
>
<TextField
margin="dense"
label={t("publish_dialog_base_url_label")}
placeholder={t("publish_dialog_base_url_placeholder")}
value={baseUrl}
onChange={(ev) => updateBaseUrl(ev.target.value)}
disabled={disabled}
type="url"
variant="standard"
sx={{ flexGrow: 1, marginRight: 1 }}
inputProps={{
"aria-label": t("publish_dialog_base_url_label"),
}}
/>
<TextField
margin="dense"
label={t("publish_dialog_topic_label")}
placeholder={t("publish_dialog_topic_placeholder")}
value={topic}
onChange={(ev) => setTopic(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
autoFocus={!messageFocused}
sx={{ flexGrow: 1 }}
inputProps={{
"aria-label": t("publish_dialog_topic_label"),
}}
/>
</ClosableRow>
)}
<TextField
margin="dense"
label={t("publish_dialog_title_label")}
placeholder={t("publish_dialog_title_placeholder")}
value={title}
onChange={(ev) => setTitle(ev.target.value)}
disabled={disabled}
type="text"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("publish_dialog_title_label"),
}}
/>
<TextField
margin="dense"
label={t("publish_dialog_message_label")}
placeholder={t("publish_dialog_message_placeholder")}
value={message}
onChange={(ev) => setMessage(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
rows={5}
autoFocus={messageFocused}
fullWidth
multiline
inputProps={{
"aria-label": t("publish_dialog_message_label"),
}}
/>
<div style={{ display: "flex" }}>
<EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} />
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
<InsertEmoticonIcon />
</DialogIconButton>
<TextField
margin="dense"
label={t("publish_dialog_tags_label")}
placeholder={t("publish_dialog_tags_placeholder")}
value={tags}
onChange={(ev) => setTags(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
sx={{ flexGrow: 1, marginRight: 1 }}
inputProps={{
"aria-label": t("publish_dialog_tags_label"),
}}
/>
<FormControl variant="standard" margin="dense" sx={{ minWidth: 170, maxWidth: 300, flexGrow: 1 }}>
<InputLabel />
<Select
label={t("publish_dialog_priority_label")}
margin="dense"
value={priority}
onChange={(ev) => setPriority(ev.target.value)}
disabled={disabled}
inputProps={{
"aria-label": t("publish_dialog_priority_label"),
}}
>
{[5, 4, 3, 2, 1].map((p) => (
<MenuItem
key={`priorityMenuItem${p}`}
value={p}
aria-label={t("notifications_priority_x", {
priority: p,
})}
>
<div style={{ display: "flex", alignItems: "center" }}>
<img
src={priorities[p].file}
style={{ marginRight: "8px" }}
alt={t("notifications_priority_x", {
priority: p,
})}
/>
<div>{priorities[p].label}</div>
</div>
</MenuItem>
))}
</Select>
</FormControl>
</div>
{showClickUrl && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_click_reset")}
onClose={() => {
setClickUrl("");
setShowClickUrl(false);
}}
>
<TextField
margin="dense"
label={t("publish_dialog_click_label")}
placeholder={t("publish_dialog_click_placeholder")}
value={clickUrl}
onChange={(ev) => setClickUrl(ev.target.value)}
disabled={disabled}
type="url"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("publish_dialog_click_label"),
}}
/>
</ClosableRow>
)}
{showEmail && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_email_reset")}
onClose={() => {
setEmail("");
setShowEmail(false);
}}
>
<TextField
margin="dense"
label={t("publish_dialog_email_label")}
placeholder={t("publish_dialog_email_placeholder")}
value={email}
onChange={(ev) => setEmail(ev.target.value)}
disabled={disabled}
type="email"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_email_label"),
}}
/>
</ClosableRow>
)}
{showCall && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_call_reset")}
onClose={() => {
setCall("");
setShowCall(false);
}}
>
<FormControl fullWidth variant="standard" margin="dense">
<InputLabel />
<Select
label={t("publish_dialog_call_label")}
margin="dense"
value={call}
onChange={(ev) => setCall(ev.target.value)}
disabled={disabled}
inputProps={{
"aria-label": t("publish_dialog_call_label"),
}}
>
{account?.phone_numbers?.map((phoneNumber) => (
<MenuItem key={phoneNumber} value={phoneNumber} aria-label={phoneNumber}>
{t("publish_dialog_call_item", { number: phoneNumber })}
</MenuItem>
))}
</Select>
</FormControl>
</ClosableRow>
)}
{showAttachUrl && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_attach_reset")}
onClose={() => {
setAttachUrl("");
setFilename("");
setFilenameEdited(false);
setShowAttachUrl(false);
}}
>
<TextField
margin="dense"
label={t("publish_dialog_attach_label")}
placeholder={t("publish_dialog_attach_placeholder")}
value={attachUrl}
onChange={(ev) => {
const url = ev.target.value;
setAttachUrl(url);
if (!filenameEdited) {
try {
const u = new URL(url);
const parts = u.pathname.split("/");
if (parts.length > 0) {
setFilename(parts[parts.length - 1]);
}
} catch (e) {
// Do nothing
}
}
}}
disabled={disabled}
type="url"
variant="standard"
sx={{ flexGrow: 5, marginRight: 1 }}
inputProps={{
"aria-label": t("publish_dialog_attach_label"),
}}
/>
<TextField
margin="dense"
label={t("publish_dialog_filename_label")}
placeholder={t("publish_dialog_filename_placeholder")}
value={filename}
onChange={(ev) => {
setFilename(ev.target.value);
setFilenameEdited(true);
}}
disabled={disabled}
type="text"
variant="standard"
sx={{ flexGrow: 1 }}
inputProps={{
"aria-label": t("publish_dialog_filename_label"),
}}
/>
</ClosableRow>
)}
<input type="file" ref={attachFileInput} onChange={handleAttachFileChanged} style={{ display: "none" }} aria-hidden />
{showAttachFile && (
<AttachmentBox
file={attachFile}
filename={filename}
disabled={disabled}
error={attachFileError}
onChangeFilename={(f) => setFilename(f)}
onClose={() => {
setAttachFile(null);
setAttachFileError("");
setFilename("");
}}
/>
)}
{showDelay && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_delay_reset")}
onClose={() => {
setDelay("");
setShowDelay(false);
}}
>
<TextField
margin="dense"
label={t("publish_dialog_delay_label")}
placeholder={t("publish_dialog_delay_placeholder", {
unixTimestamp: "1649029748",
relativeTime: "30m",
naturalLanguage: "tomorrow, 9am",
})}
value={delay}
onChange={(ev) => setDelay(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_delay_label"),
}}
/>
</ClosableRow>
)}
<Typography variant="body1" sx={{ marginTop: 2, marginBottom: 1 }}>
{t("publish_dialog_other_features")}
</Typography>
<div>
{!showClickUrl && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_click_label")}
aria-label={t("publish_dialog_chip_click_label")}
onClick={() => setShowClickUrl(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showEmail && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_email_label")}
aria-label={t("publish_dialog_chip_email_label")}
onClick={() => setShowEmail(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{account?.phone_numbers?.length > 0 && !showCall && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_call_label")}
aria-label={t("publish_dialog_chip_call_label")}
onClick={() => {
setShowCall(true);
setCall(account.phone_numbers[0]);
}}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showAttachUrl && !showAttachFile && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_attach_url_label")}
aria-label={t("publish_dialog_chip_attach_url_label")}
onClick={() => setShowAttachUrl(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showAttachFile && !showAttachUrl && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_attach_file_label")}
aria-label={t("publish_dialog_chip_attach_file_label")}
onClick={() => handleAttachFileClick()}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showDelay && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_delay_label")}
aria-label={t("publish_dialog_chip_delay_label")}
onClick={() => setShowDelay(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showTopicUrl && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_topic_label")}
aria-label={t("publish_dialog_chip_topic_label")}
onClick={() => setShowTopicUrl(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{account && !account?.phone_numbers && (
<Tooltip title={t("publish_dialog_chip_call_no_verified_numbers_tooltip")}>
<span>
<Chip
clickable
disabled
label={t("publish_dialog_chip_call_label")}
aria-label={t("publish_dialog_chip_call_label")}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
</span>
</Tooltip>
)}
</div>
<Typography variant="body1" sx={{ marginTop: 1, marginBottom: 1 }}>
<Trans
i18nKey="publish_dialog_details_examples_description"
components={{
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />,
}}
/>
</Typography>
</DialogContent>
<DialogFooter status={status}>
{activeRequest && <Button onClick={() => activeRequest.abort()}>{t("publish_dialog_button_cancel_sending")}</Button>}
{!activeRequest && (
<>
<FormControlLabel
label={t("publish_dialog_checkbox_publish_another")}
sx={{ marginRight: 2 }}
control={
<Checkbox
size="small"
checked={publishAnother}
onChange={(ev) => setPublishAnother(ev.target.checked)}
inputProps={{
"aria-label": t("publish_dialog_checkbox_publish_another"),
}}
/>
}
/>
<Button onClick={props.onClose}>{t("publish_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>
{t("publish_dialog_button_send")}
</Button>
</>
)}
</DialogFooter>
</Dialog>
</>
);
};
const Row = (props) => (
<div style={{ display: "flex" }} role="row">
{props.children}
</div>
);
const ClosableRow = (props) => {
const closable = props.closable !== undefined ? props.closable : true;
return (
<Row>
{props.children}
{closable && (
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{ marginLeft: "6px" }} aria-label={props.closeLabel}>
<Close />
</DialogIconButton>
)}
</Row>
);
};
const DialogIconButton = (props) => {
const sx = props.sx || {};
return (
<IconButton
color="inherit"
size="large"
edge="start"
sx={{ height: "45px", marginTop: "17px", ...sx }}
onClick={props.onClick}
disabled={props.disabled}
aria-label={props["aria-label"]}
>
{props.children}
</IconButton>
);
};
const AttachmentBox = (props) => {
const { t } = useTranslation();
const { file } = props;
return (
<>
<Typography variant="body1" sx={{ marginTop: 2 }}>
{t("publish_dialog_attached_file_title")}
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
padding: 0.5,
borderRadius: "4px",
}}
>
<AttachmentIcon type={file.type} />
<Box sx={{ marginLeft: 1, textAlign: "left" }}>
<ExpandingTextField
minWidth={140}
variant="body2"
placeholder={t("publish_dialog_attached_file_filename_placeholder")}
value={props.filename}
onChange={(ev) => props.onChangeFilename(ev.target.value)}
disabled={props.disabled}
/>
<br />
<Typography variant="body2" sx={{ color: "text.primary" }}>
{formatBytes(file.size)}
{props.error && (
<Typography component="span" sx={{ color: "error.main" }} aria-live="polite">
{" "}
({props.error})
</Typography>
)}
</Typography>
</Box>
<DialogIconButton
disabled={props.disabled}
onClick={props.onClose}
sx={{ marginLeft: "6px" }}
aria-label={t("publish_dialog_attached_file_remove")}
>
<Close />
</DialogIconButton>
</Box>
</>
);
};
const ExpandingTextField = (props) => {
const invisibleFieldRef = useRef();
const [textWidth, setTextWidth] = useState(props.minWidth);
const determineTextWidth = () => {
const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect();
if (!boundingRect) {
return props.minWidth;
}
return boundingRect.width >= props.minWidth ? Math.round(boundingRect.width) : props.minWidth;
};
useEffect(() => {
setTextWidth(determineTextWidth() + 5);
}, [props.value]);
return (
<>
<Typography ref={invisibleFieldRef} component="span" variant={props.variant} aria-hidden sx={{ position: "absolute", left: "-200%" }}>
{props.value}
</Typography>
<TextField
margin="dense"
placeholder={props.placeholder}
value={props.value}
onChange={props.onChange}
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,
}}
disabled={props.disabled}
/>
</>
);
};
const DropArea = (props) => {
const allowDrag = (ev) => {
// This is where we could disallow certain files to be dragged in.
// For now we allow all files.
// eslint-disable-next-line no-param-reassign
ev.dataTransfer.dropEffect = "copy";
ev.preventDefault();
};
return (
<Box
sx={{
position: "absolute",
left: 0,
top: 0,
right: 0,
bottom: 0,
zIndex: 10002,
}}
onDrop={props.onDrop}
onDragEnter={allowDrag}
onDragOver={allowDrag}
onDragLeave={props.onDragLeave}
/>
);
};
const DropBox = () => {
const { t } = useTranslation();
return (
<Box
sx={{
position: "absolute",
left: 0,
top: 0,
right: 0,
bottom: 0,
zIndex: 10000,
backgroundColor: "#ffffffbb",
}}
>
<Box
sx={{
position: "absolute",
border: "3px dashed #ccc",
borderRadius: "5px",
left: "40px",
top: "40px",
right: "40px",
bottom: "40px",
zIndex: 10001,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Typography variant="h5">{t("publish_dialog_drop_file_here")}</Typography>
</Box>
</Box>
);
};
PublishDialog.OPEN_MODE_DEFAULT = "default";
PublishDialog.OPEN_MODE_DRAG = "drag";
export default PublishDialog;

View File

@@ -1,199 +0,0 @@
import * as React from 'react';
import {useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import {Alert, FormControl, Select, useMediaQuery} from "@mui/material";
import theme from "./theme";
import {validTopic} from "../app/utils";
import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
import session from "../app/Session";
import routes from "./routes";
import accountApi, {Permission} from "../app/AccountApi";
import ReserveTopicSelect from "./ReserveTopicSelect";
import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import {Check, DeleteForever} from "@mui/icons-material";
import {TopicReservedError, UnauthorizedError} from "../app/errors";
export const ReserveAddDialog = (props) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [topic, setTopic] = useState(props.topic || "");
const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const allowTopicEdit = !props.topic;
const alreadyReserved = props.reservations.filter(r => r.topic === topic).length > 0;
const submitButtonEnabled = validTopic(topic) && !alreadyReserved;
const handleSubmit = async () => {
try {
await accountApi.upsertReservation(topic, everyone);
console.debug(`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`);
} catch (e) {
console.log(`[ReserveAddDialog] Error adding topic reservation.`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else if (e instanceof TopicReservedError) {
setError(t("subscribe_dialog_error_topic_already_reserved"));
return;
} else {
setError(e.message);
return;
}
}
props.onClose();
};
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{t("prefs_reservations_dialog_title_add")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("prefs_reservations_dialog_description")}
</DialogContentText>
{allowTopicEdit && <TextField
autoFocus
margin="dense"
id="topic"
label={t("prefs_reservations_dialog_topic_label")}
aria-label={t("prefs_reservations_dialog_topic_label")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
type="url"
fullWidth
variant="standard"
/>}
<ReserveTopicSelect
value={everyone}
onChange={setEveryone}
sx={{mt: 1}}
/>
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!submitButtonEnabled}>{t("common_add")}</Button>
</DialogFooter>
</Dialog>
);
};
export const ReserveEditDialog = (props) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleSubmit = async () => {
try {
await accountApi.upsertReservation(props.reservation.topic, everyone);
console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`);
} catch (e) {
console.log(`[ReserveEditDialog] Error updating topic reservation.`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
}
}
props.onClose();
};
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{t("prefs_reservations_dialog_title_edit")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("prefs_reservations_dialog_description")}
</DialogContentText>
<ReserveTopicSelect
value={everyone}
onChange={setEveryone}
sx={{mt: 1}}
/>
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit}>{t("common_save")}</Button>
</DialogFooter>
</Dialog>
);
};
export const ReserveDeleteDialog = (props) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [deleteMessages, setDeleteMessages] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleSubmit = async () => {
try {
await accountApi.deleteReservation(props.topic, deleteMessages);
console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`);
} catch (e) {
console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
}
}
props.onClose();
};
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{t("prefs_reservations_dialog_title_delete")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("reservation_delete_dialog_description")}
</DialogContentText>
<FormControl fullWidth variant="standard">
<Select
value={deleteMessages}
onChange={(ev) => setDeleteMessages(ev.target.value)}
sx={{
"& .MuiSelect-select": {
display: 'flex',
alignItems: 'center',
paddingTop: "4px",
paddingBottom: "4px",
}
}}
>
<MenuItem value={false}>
<ListItemIcon><Check/></ListItemIcon>
<ListItemText primary={t("reservation_delete_dialog_action_keep_title")}/>
</MenuItem>
<MenuItem value={true}>
<ListItemIcon><DeleteForever/></ListItemIcon>
<ListItemText primary={t("reservation_delete_dialog_action_delete_title")}/>
</MenuItem>
</Select>
</FormControl>
{!deleteMessages &&
<Alert severity="info" sx={{ mt: 1 }}>
{t("reservation_delete_dialog_action_keep_description")}
</Alert>
}
{deleteMessages &&
<Alert severity="warning" sx={{ mt: 1 }}>
{t("reservation_delete_dialog_action_delete_description")}
</Alert>
}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} color="error">{t("reservation_delete_dialog_submit_button")}</Button>
</DialogFooter>
</Dialog>
);
};

View File

@@ -0,0 +1,199 @@
import * as React from "react";
import { useState } from "react";
import {
Button,
TextField,
Dialog,
DialogContent,
DialogContentText,
DialogTitle,
Alert,
FormControl,
Select,
useMediaQuery,
MenuItem,
ListItemIcon,
ListItemText,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { Check, DeleteForever } from "@mui/icons-material";
import theme from "./theme";
import { validTopic } from "../app/utils";
import DialogFooter from "./DialogFooter";
import session from "../app/Session";
import routes from "./routes";
import accountApi, { Permission } from "../app/AccountApi";
import ReserveTopicSelect from "./ReserveTopicSelect";
import { TopicReservedError, UnauthorizedError } from "../app/errors";
export const ReserveAddDialog = (props) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [topic, setTopic] = useState(props.topic || "");
const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const allowTopicEdit = !props.topic;
const alreadyReserved = props.reservations.filter((r) => r.topic === topic).length > 0;
const submitButtonEnabled = validTopic(topic) && !alreadyReserved;
const handleSubmit = async () => {
try {
await accountApi.upsertReservation(topic, everyone);
console.debug(`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`);
} catch (e) {
console.log(`[ReserveAddDialog] Error adding topic reservation.`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else if (e instanceof TopicReservedError) {
setError(t("subscribe_dialog_error_topic_already_reserved"));
return;
} else {
setError(e.message);
return;
}
}
props.onClose();
};
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{t("prefs_reservations_dialog_title_add")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("prefs_reservations_dialog_description")}</DialogContentText>
{allowTopicEdit && (
<TextField
autoFocus
margin="dense"
id="topic"
label={t("prefs_reservations_dialog_topic_label")}
aria-label={t("prefs_reservations_dialog_topic_label")}
value={topic}
onChange={(ev) => setTopic(ev.target.value)}
type="url"
fullWidth
variant="standard"
/>
)}
<ReserveTopicSelect value={everyone} onChange={setEveryone} sx={{ mt: 1 }} />
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!submitButtonEnabled}>
{t("common_add")}
</Button>
</DialogFooter>
</Dialog>
);
};
export const ReserveEditDialog = (props) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSubmit = async () => {
try {
await accountApi.upsertReservation(props.reservation.topic, everyone);
console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`);
} catch (e) {
console.log(`[ReserveEditDialog] Error updating topic reservation.`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
}
}
props.onClose();
};
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{t("prefs_reservations_dialog_title_edit")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("prefs_reservations_dialog_description")}</DialogContentText>
<ReserveTopicSelect value={everyone} onChange={setEveryone} sx={{ mt: 1 }} />
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit}>{t("common_save")}</Button>
</DialogFooter>
</Dialog>
);
};
export const ReserveDeleteDialog = (props) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [deleteMessages, setDeleteMessages] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSubmit = async () => {
try {
await accountApi.deleteReservation(props.topic, deleteMessages);
console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`);
} catch (e) {
console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
}
}
props.onClose();
};
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{t("prefs_reservations_dialog_title_delete")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("reservation_delete_dialog_description")}</DialogContentText>
<FormControl fullWidth variant="standard">
<Select
value={deleteMessages}
onChange={(ev) => setDeleteMessages(ev.target.value)}
sx={{
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
paddingTop: "4px",
paddingBottom: "4px",
},
}}
>
<MenuItem value={false}>
<ListItemIcon>
<Check />
</ListItemIcon>
<ListItemText primary={t("reservation_delete_dialog_action_keep_title")} />
</MenuItem>
<MenuItem value>
<ListItemIcon>
<DeleteForever />
</ListItemIcon>
<ListItemText primary={t("reservation_delete_dialog_action_delete_title")} />
</MenuItem>
</Select>
</FormControl>
{!deleteMessages && (
<Alert severity="info" sx={{ mt: 1 }}>
{t("reservation_delete_dialog_action_keep_description")}
</Alert>
)}
{deleteMessages && (
<Alert severity="warning" sx={{ mt: 1 }}>
{t("reservation_delete_dialog_action_delete_description")}
</Alert>
)}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} color="error">
{t("reservation_delete_dialog_submit_button")}
</Button>
</DialogFooter>
</Dialog>
);
};

View File

@@ -1,46 +0,0 @@
import * as React from 'react';
import {Lock, Public} from "@mui/icons-material";
import Box from "@mui/material/Box";
export const PermissionReadWrite = React.forwardRef((props, ref) => {
return <PermissionInternal icon={Public} ref={ref} {...props}/>;
});
export const PermissionDenyAll = React.forwardRef((props, ref) => {
return <PermissionInternal icon={Lock} ref={ref} {...props}/>;
});
export const PermissionRead = React.forwardRef((props, ref) => {
return <PermissionInternal icon={Public} text="R" ref={ref} {...props}/>;
});
export const PermissionWrite = React.forwardRef((props, ref) => {
return <PermissionInternal icon={Public} text="W" ref={ref} {...props}/>;
});
const PermissionInternal = React.forwardRef((props, ref) => {
const size = props.size ?? "medium";
const Icon = props.icon;
return (
<Box ref={ref} {...props} style={{ position: "relative", display: "inline-flex", verticalAlign: "middle", height: "24px" }}>
<Icon fontSize={size} sx={{ color: "gray" }}/>
{props.text &&
<Box
sx={{
position: "absolute",
right: "-6px",
bottom: "5px",
fontSize: 10,
fontWeight: 600,
color: "gray",
width: "8px",
height: "8px",
marginTop: "3px"
}}
>
{props.text}
</Box>
}
</Box>
);
});

View File

@@ -0,0 +1,47 @@
import * as React from "react";
import { Lock, Public } from "@mui/icons-material";
import { Box } from "@mui/material";
export const PermissionReadWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} ref={ref} {...props} />);
export const PermissionDenyAll = React.forwardRef((props, ref) => <PermissionInternal icon={Lock} ref={ref} {...props} />);
export const PermissionRead = React.forwardRef((props, ref) => <PermissionInternal icon={Public} text="R" ref={ref} {...props} />);
export const PermissionWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} text="W" ref={ref} {...props} />);
const PermissionInternal = React.forwardRef((props, ref) => {
const size = props.size ?? "medium";
const Icon = props.icon;
return (
<Box
ref={ref}
{...props}
style={{
position: "relative",
display: "inline-flex",
verticalAlign: "middle",
height: "24px",
}}
>
<Icon fontSize={size} sx={{ color: "gray" }} />
{props.text && (
<Box
sx={{
position: "absolute",
right: "-6px",
bottom: "5px",
fontSize: 10,
fontWeight: 600,
color: "gray",
width: "8px",
height: "8px",
marginTop: "3px",
}}
>
{props.text}
</Box>
)}
</Box>
);
});

View File

@@ -1,49 +0,0 @@
import * as React from 'react';
import {FormControl, Select} from "@mui/material";
import {useTranslation} from "react-i18next";
import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
import {Permission} from "../app/AccountApi";
const ReserveTopicSelect = (props) => {
const { t } = useTranslation();
const sx = props.sx || {};
return (
<FormControl fullWidth variant="standard" sx={sx}>
<Select
value={props.value}
onChange={(ev) => props.onChange(ev.target.value)}
aria-label={t("prefs_reservations_dialog_access_label")}
sx={{
"& .MuiSelect-select": {
display: 'flex',
alignItems: 'center',
paddingTop: "4px",
paddingBottom: "4px",
}
}}
>
<MenuItem value={Permission.DENY_ALL}>
<ListItemIcon><PermissionDenyAll/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_deny_all")}/>
</MenuItem>
<MenuItem value={Permission.READ_ONLY}>
<ListItemIcon><PermissionRead/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_read_only")}/>
</MenuItem>
<MenuItem value={Permission.WRITE_ONLY}>
<ListItemIcon><PermissionWrite/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_write_only")}/>
</MenuItem>
<MenuItem value={Permission.READ_WRITE}>
<ListItemIcon><PermissionReadWrite/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_read_write")}/>
</MenuItem>
</Select>
</FormControl>
);
};
export default ReserveTopicSelect;

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { FormControl, Select, MenuItem, ListItemIcon, ListItemText } from "@mui/material";
import { useTranslation } from "react-i18next";
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
import { Permission } from "../app/AccountApi";
const ReserveTopicSelect = (props) => {
const { t } = useTranslation();
const sx = props.sx || {};
return (
<FormControl fullWidth variant="standard" sx={sx}>
<Select
value={props.value}
onChange={(ev) => props.onChange(ev.target.value)}
aria-label={t("prefs_reservations_dialog_access_label")}
sx={{
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
paddingTop: "4px",
paddingBottom: "4px",
},
}}
>
<MenuItem value={Permission.DENY_ALL}>
<ListItemIcon>
<PermissionDenyAll />
</ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_deny_all")} />
</MenuItem>
<MenuItem value={Permission.READ_ONLY}>
<ListItemIcon>
<PermissionRead />
</ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_read_only")} />
</MenuItem>
<MenuItem value={Permission.WRITE_ONLY}>
<ListItemIcon>
<PermissionWrite />
</ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_write_only")} />
</MenuItem>
<MenuItem value={Permission.READ_WRITE}>
<ListItemIcon>
<PermissionReadWrite />
</ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_read_write")} />
</MenuItem>
</Select>
</FormControl>
);
};
export default ReserveTopicSelect;

View File

@@ -1,158 +0,0 @@
import * as React from 'react';
import {useState} from 'react';
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import routes from "./routes";
import session from "../app/Session";
import Typography from "@mui/material/Typography";
import {NavLink} from "react-router-dom";
import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import accountApi from "../app/AccountApi";
import {InputAdornment} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import {Visibility, VisibilityOff} from "@mui/icons-material";
import {AccountCreateLimitReachedError, UserExistsError} from "../app/errors";
const Signup = () => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault();
const user = { username, password };
try {
await accountApi.create(user.username, user.password);
const token = await accountApi.login(user);
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
session.store(user.username, token);
window.location.href = routes.app;
} catch (e) {
console.log(`[Signup] Signup for user ${user.username} failed`, e);
if (e instanceof UserExistsError) {
setError(t("signup_error_username_taken", { username: e.username }));
} else if ((e instanceof AccountCreateLimitReachedError)) {
setError(t("signup_error_creation_limit_reached"));
} else {
setError(e.message);
}
}
};
if (!config.enable_signup) {
return (
<AvatarBox>
<Typography sx={{ typography: 'h6' }}>{t("signup_disabled")}</Typography>
</AvatarBox>
);
}
return (
<AvatarBox>
<Typography sx={{ typography: 'h6' }}>
{t("signup_title")}
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}>
<TextField
margin="dense"
required
fullWidth
id="username"
label={t("signup_form_username")}
name="username"
value={username}
onChange={ev => setUsername(ev.target.value.trim())}
autoFocus
/>
<TextField
margin="dense"
required
fullWidth
name="password"
label={t("signup_form_password")}
type={showPassword ? "text" : "password"}
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>
)
}}
/>
<TextField
margin="dense"
required
fullWidth
name="password"
label={t("signup_form_confirm_password")}
type={showConfirm ? "text" : "password"}
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>
)
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
disabled={username === "" || password === "" || password !== confirm}
sx={{mt: 2, mb: 2}}
>
{t("signup_form_button_submit")}
</Button>
{error &&
<Box sx={{
mb: 1,
display: 'flex',
flexGrow: 1,
justifyContent: 'center',
}}>
<WarningAmberIcon color="error" sx={{mr: 1}}/>
<Typography sx={{color: 'error.main'}}>{error}</Typography>
</Box>
}
</Box>
{config.enable_login &&
<Typography sx={{mb: 4}}>
<NavLink to={routes.login} variant="body1">
{t("signup_already_have_account")}
</NavLink>
</Typography>
}
</AvatarBox>
);
}
export default Signup;

View File

@@ -0,0 +1,153 @@
import * as React from "react";
import { useState } from "react";
import { TextField, Button, Box, Typography, InputAdornment, IconButton } from "@mui/material";
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 accountApi from "../app/AccountApi";
import AvatarBox from "./AvatarBox";
import session from "../app/Session";
import routes from "./routes";
import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors";
const Signup = () => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault();
const user = { username, password };
try {
await accountApi.create(user.username, user.password);
const token = await accountApi.login(user);
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
session.store(user.username, token);
window.location.href = routes.app;
} catch (e) {
console.log(`[Signup] Signup for user ${user.username} failed`, e);
if (e instanceof UserExistsError) {
setError(t("signup_error_username_taken", { username: e.username }));
} else if (e instanceof AccountCreateLimitReachedError) {
setError(t("signup_error_creation_limit_reached"));
} else {
setError(e.message);
}
}
};
if (!config.enable_signup) {
return (
<AvatarBox>
<Typography sx={{ typography: "h6" }}>{t("signup_disabled")}</Typography>
</AvatarBox>
);
}
return (
<AvatarBox>
<Typography sx={{ typography: "h6" }}>{t("signup_title")}</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1, maxWidth: 400 }}>
<TextField
margin="dense"
required
fullWidth
id="username"
label={t("signup_form_username")}
name="username"
value={username}
onChange={(ev) => setUsername(ev.target.value.trim())}
autoFocus
/>
<TextField
margin="dense"
required
fullWidth
name="password"
label={t("signup_form_password")}
type={showPassword ? "text" : "password"}
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>
),
}}
/>
<TextField
margin="dense"
required
fullWidth
name="password"
label={t("signup_form_confirm_password")}
type={showConfirm ? "text" : "password"}
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>
),
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
disabled={username === "" || password === "" || password !== confirm}
sx={{ mt: 2, mb: 2 }}
>
{t("signup_form_button_submit")}
</Button>
{error && (
<Box
sx={{
mb: 1,
display: "flex",
flexGrow: 1,
justifyContent: "center",
}}
>
<WarningAmberIcon color="error" sx={{ mr: 1 }} />
<Typography sx={{ color: "error.main" }}>{error}</Typography>
</Box>
)}
</Box>
{config.enable_login && (
<Typography sx={{ mb: 4 }}>
<NavLink to={routes.login} variant="body1">
{t("signup_already_have_account")}
</NavLink>
</Typography>
)}
</AvatarBox>
);
};
export default Signup;

View File

@@ -1,313 +0,0 @@
import * as React from 'react';
import {useContext, useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import {Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery} from "@mui/material";
import theme from "./theme";
import api from "../app/Api";
import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils";
import userManager from "../app/UserManager";
import subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller";
import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
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 {TopicReservedError, UnauthorizedError} from "../app/errors";
import {ReserveLimitChip} from "./SubscriptionPopup";
const publicBaseUrl = "https://ntfy.sh";
const SubscribeDialog = (props) => {
const [baseUrl, setBaseUrl] = useState("");
const [topic, setTopic] = useState("");
const [showLoginPage, setShowLoginPage] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleSuccess = async () => {
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
const actualBaseUrl = (baseUrl) ? baseUrl : config.base_url;
const subscription = await subscribeTopic(actualBaseUrl, topic);
poller.pollInBackground(subscription); // Dangle!
props.onSuccess(subscription);
}
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
{!showLoginPage && <SubscribePage
baseUrl={baseUrl}
setBaseUrl={setBaseUrl}
topic={topic}
setTopic={setTopic}
subscriptions={props.subscriptions}
onCancel={props.onCancel}
onNeedsLogin={() => setShowLoginPage(true)}
onSuccess={handleSuccess}
/>}
{showLoginPage && <LoginPage
baseUrl={baseUrl}
topic={topic}
onBack={() => setShowLoginPage(false)}
onSuccess={handleSuccess}
/>}
</Dialog>
);
};
const SubscribePage = (props) => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const [error, setError] = useState("");
const [reserveTopicVisible, setReserveTopicVisible] = useState(false);
const [anotherServerVisible, setAnotherServerVisible] = useState(false);
const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const baseUrl = (anotherServerVisible) ? props.baseUrl : config.base_url;
const topic = props.topic;
const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic));
const existingBaseUrls = Array
.from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)]))
.filter(s => s !== config.base_url);
const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account);
const reserveTopicEnabled = session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0));
const handleSubscribe = async () => {
const user = await userManager.get(baseUrl); // May be undefined
const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous");
// Check read access to topic
const success = await api.topicAuth(baseUrl, topic, user);
if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
if (user) {
setError(t("subscribe_dialog_error_user_not_authorized", { username: username }));
return;
} else {
props.onNeedsLogin();
return;
}
}
// Reserve topic (if requested)
if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) {
console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`);
try {
await accountApi.upsertReservation(topic, everyone);
} catch (e) {
console.log(`[SubscribeDialog] Error reserving topic`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else if (e instanceof TopicReservedError) {
setError(t("subscribe_dialog_error_topic_already_reserved"));
return;
}
}
}
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
props.onSuccess();
};
const handleUseAnotherChanged = (e) => {
props.setBaseUrl("");
setAnotherServerVisible(e.target.checked);
};
const subscribeButtonEnabled = (() => {
if (anotherServerVisible) {
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
} else {
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic));
return validTopic(topic) && !isExistingTopicUrl;
}
})();
const updateBaseUrl = (ev, newVal) => {
if (validUrl(newVal)) {
props.setBaseUrl(newVal.replace(/\/$/, '')); // strip trailing slash after https?://
} else {
props.setBaseUrl(newVal);
}
};
return (
<>
<DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("subscribe_dialog_subscribe_description")}
</DialogContentText>
<div style={{display: 'flex', paddingBottom: "8px"}} role="row">
<TextField
autoFocus
margin="dense"
id="topic"
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
value={props.topic}
onChange={ev => props.setTopic(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
maxLength: 64,
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder")
}}
/>
<Button onClick={() => {props.setTopic(randomAlphanumericString(16))}} style={{flexShrink: "0", marginTop: "0.5em"}}>
{t("subscribe_dialog_subscribe_button_generate_topic_name")}
</Button>
</div>
{showReserveTopicCheckbox &&
<FormGroup>
<FormControlLabel
variant="standard"
control={
<Checkbox
fullWidth
disabled={!reserveTopicEnabled}
checked={reserveTopicVisible}
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
inputProps={{
"aria-label": t("reserve_dialog_checkbox_label")
}}
/>
}
label={
<>
{t("reserve_dialog_checkbox_label")}
<ReserveLimitChip/>
</>
}
/>
{reserveTopicVisible &&
<ReserveTopicSelect
value={everyone}
onChange={setEveryone}
/>
}
</FormGroup>
}
{!reserveTopicVisible &&
<FormGroup>
<FormControlLabel
control={
<Checkbox
onChange={handleUseAnotherChanged}
inputProps={{
"aria-label": t("subscribe_dialog_subscribe_use_another_label")
}}
/>
}
label={t("subscribe_dialog_subscribe_use_another_label")}/>
{anotherServerVisible && <Autocomplete
freeSolo
options={existingBaseUrls}
inputValue={props.baseUrl}
onInputChange={updateBaseUrl}
renderInput={(params) =>
<TextField
{...params}
placeholder={config.base_url}
variant="standard"
aria-label={t("subscribe_dialog_subscribe_base_url_label")}
/>
}
/>}
</FormGroup>
}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>{t("subscribe_dialog_subscribe_button_subscribe")}</Button>
</DialogFooter>
</>
);
};
const LoginPage = (props) => {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const baseUrl = (props.baseUrl) ? props.baseUrl : config.base_url;
const topic = props.topic;
const handleLogin = async () => {
const user = {baseUrl, username, password};
const success = await api.topicAuth(baseUrl, topic, user);
if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
setError(t("subscribe_dialog_error_user_not_authorized", { username: username }));
return;
}
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
await userManager.save(user);
props.onSuccess();
};
return (
<>
<DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("subscribe_dialog_login_description")}
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="username"
label={t("subscribe_dialog_login_username_label")}
value={username}
onChange={ev => setUsername(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_username_label")
}}
/>
<TextField
margin="dense"
id="password"
label={t("subscribe_dialog_login_password_label")}
type="password"
value={password}
onChange={ev => setPassword(ev.target.value)}
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_password_label")
}}
/>
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onBack}>{t("common_back")}</Button>
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
</DialogFooter>
</>
);
};
export const subscribeTopic = async (baseUrl, topic) => {
const subscription = await subscriptionManager.add(baseUrl, topic);
if (session.exists()) {
try {
await accountApi.addSubscription(baseUrl, topic);
} catch (e) {
console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
return subscription;
};
export default SubscribeDialog;

View File

@@ -0,0 +1,320 @@
import * as React from "react";
import { useContext, useState } from "react";
import {
Button,
TextField,
Dialog,
DialogContent,
DialogContentText,
DialogTitle,
Autocomplete,
Checkbox,
FormControlLabel,
FormGroup,
useMediaQuery,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import theme from "./theme";
import api from "../app/Api";
import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils";
import userManager from "../app/UserManager";
import subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller";
import DialogFooter from "./DialogFooter";
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 { TopicReservedError, UnauthorizedError } from "../app/errors";
import { ReserveLimitChip } from "./SubscriptionPopup";
const publicBaseUrl = "https://ntfy.sh";
export const subscribeTopic = async (baseUrl, topic) => {
const subscription = await subscriptionManager.add(baseUrl, topic);
if (session.exists()) {
try {
await accountApi.addSubscription(baseUrl, topic);
} catch (e) {
console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
return subscription;
};
const SubscribeDialog = (props) => {
const [baseUrl, setBaseUrl] = useState("");
const [topic, setTopic] = useState("");
const [showLoginPage, setShowLoginPage] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSuccess = async () => {
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
const actualBaseUrl = baseUrl || config.base_url;
const subscription = await subscribeTopic(actualBaseUrl, topic);
poller.pollInBackground(subscription); // Dangle!
props.onSuccess(subscription);
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
{!showLoginPage && (
<SubscribePage
baseUrl={baseUrl}
setBaseUrl={setBaseUrl}
topic={topic}
setTopic={setTopic}
subscriptions={props.subscriptions}
onCancel={props.onCancel}
onNeedsLogin={() => setShowLoginPage(true)}
onSuccess={handleSuccess}
/>
)}
{showLoginPage && <LoginPage baseUrl={baseUrl} topic={topic} onBack={() => setShowLoginPage(false)} onSuccess={handleSuccess} />}
</Dialog>
);
};
const SubscribePage = (props) => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const [error, setError] = useState("");
const [reserveTopicVisible, setReserveTopicVisible] = useState(false);
const [anotherServerVisible, setAnotherServerVisible] = useState(false);
const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url;
const { topic } = props;
const existingTopicUrls = props.subscriptions.map((s) => topicUrl(s.baseUrl, s.topic));
const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])).filter(
(s) => s !== config.base_url
);
const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account);
const reserveTopicEnabled =
session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0));
const handleSubscribe = async () => {
const user = await userManager.get(baseUrl); // May be undefined
const username = user ? user.username : t("subscribe_dialog_error_user_anonymous");
// Check read access to topic
const success = await api.topicAuth(baseUrl, topic, user);
if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
if (user) {
setError(
t("subscribe_dialog_error_user_not_authorized", {
username,
})
);
return;
}
props.onNeedsLogin();
return;
}
// Reserve topic (if requested)
if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) {
console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`);
try {
await accountApi.upsertReservation(topic, everyone);
} catch (e) {
console.log(`[SubscribeDialog] Error reserving topic`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else if (e instanceof TopicReservedError) {
setError(t("subscribe_dialog_error_topic_already_reserved"));
return;
}
}
}
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
props.onSuccess();
};
const handleUseAnotherChanged = (e) => {
props.setBaseUrl("");
setAnotherServerVisible(e.target.checked);
};
const subscribeButtonEnabled = (() => {
if (anotherServerVisible) {
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
}
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic));
return validTopic(topic) && !isExistingTopicUrl;
})();
const updateBaseUrl = (ev, newVal) => {
if (validUrl(newVal)) {
props.setBaseUrl(newVal.replace(/\/$/, "")); // strip trailing slash after https?://
} else {
props.setBaseUrl(newVal);
}
};
return (
<>
<DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("subscribe_dialog_subscribe_description")}</DialogContentText>
<div style={{ display: "flex", paddingBottom: "8px" }} role="row">
<TextField
autoFocus
margin="dense"
id="topic"
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
value={props.topic}
onChange={(ev) => props.setTopic(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
maxLength: 64,
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder"),
}}
/>
<Button
onClick={() => {
props.setTopic(randomAlphanumericString(16));
}}
style={{ flexShrink: "0", marginTop: "0.5em" }}
>
{t("subscribe_dialog_subscribe_button_generate_topic_name")}
</Button>
</div>
{showReserveTopicCheckbox && (
<FormGroup>
<FormControlLabel
variant="standard"
control={
<Checkbox
fullWidth
disabled={!reserveTopicEnabled}
checked={reserveTopicVisible}
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
inputProps={{
"aria-label": t("reserve_dialog_checkbox_label"),
}}
/>
}
label={
<>
{t("reserve_dialog_checkbox_label")}
<ReserveLimitChip />
</>
}
/>
{reserveTopicVisible && <ReserveTopicSelect value={everyone} onChange={setEveryone} />}
</FormGroup>
)}
{!reserveTopicVisible && (
<FormGroup>
<FormControlLabel
control={
<Checkbox
onChange={handleUseAnotherChanged}
inputProps={{
"aria-label": t("subscribe_dialog_subscribe_use_another_label"),
}}
/>
}
label={t("subscribe_dialog_subscribe_use_another_label")}
/>
{anotherServerVisible && (
<Autocomplete
freeSolo
options={existingBaseUrls}
inputValue={props.baseUrl}
onInputChange={updateBaseUrl}
renderInput={(params) => (
<TextField
{...params}
placeholder={config.base_url}
variant="standard"
aria-label={t("subscribe_dialog_subscribe_base_url_label")}
/>
)}
/>
)}
</FormGroup>
)}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>
{t("subscribe_dialog_subscribe_button_subscribe")}
</Button>
</DialogFooter>
</>
);
};
const LoginPage = (props) => {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const baseUrl = props.baseUrl ? props.baseUrl : config.base_url;
const { topic } = props;
const handleLogin = async () => {
const user = { baseUrl, username, password };
const success = await api.topicAuth(baseUrl, topic, user);
if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
setError(t("subscribe_dialog_error_user_not_authorized", { username }));
return;
}
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
await userManager.save(user);
props.onSuccess();
};
return (
<>
<DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("subscribe_dialog_login_description")}</DialogContentText>
<TextField
autoFocus
margin="dense"
id="username"
label={t("subscribe_dialog_login_username_label")}
value={username}
onChange={(ev) => setUsername(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_username_label"),
}}
/>
<TextField
margin="dense"
id="password"
label={t("subscribe_dialog_login_password_label")}
type="password"
value={password}
onChange={(ev) => setPassword(ev.target.value)}
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_password_label"),
}}
/>
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onBack}>{t("common_back")}</Button>
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
</DialogFooter>
</>
);
};
export default SubscribeDialog;

View File

@@ -1,292 +0,0 @@
import * as React from 'react';
import {useContext, useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import {Chip, InputAdornment, Portal, Snackbar, useMediaQuery} from "@mui/material";
import theme from "./theme";
import subscriptionManager from "../app/SubscriptionManager";
import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
import accountApi, {Role} from "../app/AccountApi";
import session from "../app/Session";
import routes from "./routes";
import MenuItem from "@mui/material/MenuItem";
import PopupMenu from "./PopupMenu";
import {formatShortDateTime, shuffle} from "../app/utils";
import api from "../app/Api";
import {useNavigate} from "react-router-dom";
import IconButton from "@mui/material/IconButton";
import {Clear} from "@mui/icons-material";
import {AccountContext} from "./App";
import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs";
import {UnauthorizedError} from "../app/errors";
export const SubscriptionPopup = (props) => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const navigate = useNavigate();
const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false);
const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false);
const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false);
const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false);
const [showPublishError, setShowPublishError] = useState(false);
const subscription = props.subscription;
const placement = props.placement ?? "left";
const reservations = account?.reservations || [];
const showReservationAdd = config.enable_reservations && !subscription?.reservation && account?.stats.reservations_remaining > 0;
const showReservationAddDisabled = !showReservationAdd && config.enable_reservations && !subscription?.reservation && (config.enable_payments || account?.stats.reservations_remaining === 0);
const showReservationEdit = config.enable_reservations && !!subscription?.reservation;
const showReservationDelete = config.enable_reservations && !!subscription?.reservation;
const handleChangeDisplayName = async () => {
setDisplayNameDialogOpen(true);
}
const handleReserveAdd = async () => {
setReserveAddDialogOpen(true);
}
const handleReserveEdit = async () => {
setReserveEditDialogOpen(true);
}
const handleReserveDelete = async () => {
setReserveDeleteDialogOpen(true);
}
const handleSendTestMessage = async () => {
const baseUrl = props.subscription.baseUrl;
const topic = props.subscription.topic;
const tags = shuffle([
"grinning", "octopus", "upside_down_face", "palm_tree", "maple_leaf", "apple", "skull", "warning", "jack_o_lantern",
"de-server-1", "backups", "cron-script", "script-error", "phils-automation", "mouse", "go-rocks", "hi-ben"])
.slice(0, Math.round(Math.random() * 4));
const priority = shuffle([1, 2, 3, 4, 5])[0];
const title = shuffle([
"",
"",
"", // Higher chance of no title
"Oh my, another test message?",
"Titles are optional, did you know that?",
"ntfy is open source, and will always be free. Cool, right?",
"I don't really like apples",
"My favorite TV show is The Wire. You should watch it!",
"You can attach files and URLs to messages too",
"You can delay messages up to 3 days"
])[0];
const nowSeconds = Math.round(Date.now()/1000);
const message = shuffle([
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
`Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`
])[0];
try {
await api.publish(baseUrl, topic, message, {
title: title,
priority: priority,
tags: tags
});
} catch (e) {
console.log(`[SubscriptionPopup] Error publishing message`, e);
setShowPublishError(true);
}
}
const handleClearAll = async () => {
console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`);
await subscriptionManager.deleteNotifications(props.subscription.id);
};
const handleUnsubscribe = async () => {
console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription);
await subscriptionManager.remove(props.subscription.id);
if (session.exists() && !subscription.internal) {
try {
await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic);
} catch (e) {
console.log(`[SubscriptionPopup] Error unsubscribing`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
const newSelected = await subscriptionManager.first(); // May be undefined
if (newSelected && !newSelected.internal) {
navigate(routes.forSubscription(newSelected));
} else {
navigate(routes.app);
}
};
return (
<>
<PopupMenu
horizontal={placement}
anchorEl={props.anchor}
open={!!props.anchor}
onClose={props.onClose}
>
<MenuItem onClick={handleChangeDisplayName}>{t("action_bar_change_display_name")}</MenuItem>
{showReservationAdd && <MenuItem onClick={handleReserveAdd}>{t("action_bar_reservation_add")}</MenuItem>}
{showReservationAddDisabled &&
<MenuItem sx={{ cursor: "default" }}>
<span style={{ opacity: 0.3 }}>{t("action_bar_reservation_add")}</span>
<ReserveLimitChip/>
</MenuItem>
}
{showReservationEdit && <MenuItem onClick={handleReserveEdit}>{t("action_bar_reservation_edit")}</MenuItem>}
{showReservationDelete && <MenuItem onClick={handleReserveDelete}>{t("action_bar_reservation_delete")}</MenuItem>}
<MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
<MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
<MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
</PopupMenu>
<Portal>
<Snackbar
open={showPublishError}
autoHideDuration={3000}
onClose={() => setShowPublishError(false)}
message={t("message_bar_error_publishing")}
/>
<DisplayNameDialog
open={displayNameDialogOpen}
subscription={subscription}
onClose={() => setDisplayNameDialogOpen(false)}
/>
{showReservationAdd &&
<ReserveAddDialog
open={reserveAddDialogOpen}
topic={subscription.topic}
reservations={reservations}
onClose={() => setReserveAddDialogOpen(false)}
/>
}
{showReservationEdit &&
<ReserveEditDialog
open={reserveEditDialogOpen}
reservation={subscription.reservation}
reservations={props.reservations}
onClose={() => setReserveEditDialogOpen(false)}
/>
}
{showReservationDelete &&
<ReserveDeleteDialog
open={reserveDeleteDialogOpen}
topic={subscription.topic}
onClose={() => setReserveDeleteDialogOpen(false)}
/>
}
</Portal>
</>
);
};
const DisplayNameDialog = (props) => {
const { t } = useTranslation();
const subscription = props.subscription;
const [error, setError] = useState("");
const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleSave = async () => {
await subscriptionManager.setDisplayName(subscription.id, displayName);
if (session.exists() && !subscription.internal) {
try {
console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`);
await accountApi.updateSubscription(subscription.baseUrl, subscription.topic, { display_name: displayName });
} catch (e) {
console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
}
}
}
props.onClose();
}
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{t("display_name_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("display_name_dialog_description")}
</DialogContentText>
<TextField
autoFocus
placeholder={t("display_name_dialog_placeholder")}
value={displayName}
onChange={ev => setDisplayName(ev.target.value)}
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>
)
}}
/>
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSave}>{t("common_save")}</Button>
</DialogFooter>
</Dialog>
);
};
export const ReserveLimitChip = () => {
const { account } = useContext(AccountContext);
if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) {
return <></>;
} else if (config.enable_payments) {
return (account?.limits.reservations > 0) ? <LimitReachedChip/> : <ProChip/>;
} else if (account) {
return <LimitReachedChip/>;
}
return <></>;
};
const LimitReachedChip = () => {
const { t } = useTranslation();
return (
<Chip
label={t("action_bar_reservation_limit_reached")}
variant="outlined"
color="primary"
sx={{ opacity: 0.8, borderWidth: "2px", height: "24px", marginLeft: "5px" }}
/>
);
};
export const ProChip = () => {
const { t } = useTranslation();
return (
<Chip
label={"ntfy Pro"}
variant="outlined"
color="primary"
sx={{ opacity: 0.8, fontWeight: "bold", borderWidth: "2px", height: "24px", marginLeft: "5px" }}
/>
);
};

View File

@@ -0,0 +1,314 @@
import * as React from "react";
import { useContext, useState } from "react";
import {
Button,
TextField,
Dialog,
DialogContent,
DialogContentText,
DialogTitle,
Chip,
InputAdornment,
Portal,
Snackbar,
useMediaQuery,
MenuItem,
IconButton,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Clear } from "@mui/icons-material";
import theme from "./theme";
import subscriptionManager from "../app/SubscriptionManager";
import DialogFooter from "./DialogFooter";
import accountApi, { Role } from "../app/AccountApi";
import session from "../app/Session";
import routes from "./routes";
import PopupMenu from "./PopupMenu";
import { formatShortDateTime, shuffle } from "../app/utils";
import api from "../app/Api";
import { AccountContext } from "./App";
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors";
export const SubscriptionPopup = (props) => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const navigate = useNavigate();
const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false);
const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false);
const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false);
const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false);
const [showPublishError, setShowPublishError] = useState(false);
const { subscription } = props;
const placement = props.placement ?? "left";
const reservations = account?.reservations || [];
const showReservationAdd = config.enable_reservations && !subscription?.reservation && account?.stats.reservations_remaining > 0;
const showReservationAddDisabled =
!showReservationAdd &&
config.enable_reservations &&
!subscription?.reservation &&
(config.enable_payments || account?.stats.reservations_remaining === 0);
const showReservationEdit = config.enable_reservations && !!subscription?.reservation;
const showReservationDelete = config.enable_reservations && !!subscription?.reservation;
const handleChangeDisplayName = async () => {
setDisplayNameDialogOpen(true);
};
const handleReserveAdd = async () => {
setReserveAddDialogOpen(true);
};
const handleReserveEdit = async () => {
setReserveEditDialogOpen(true);
};
const handleReserveDelete = async () => {
setReserveDeleteDialogOpen(true);
};
const handleSendTestMessage = async () => {
const { baseUrl } = props.subscription;
const { topic } = props.subscription;
const tags = shuffle([
"grinning",
"octopus",
"upside_down_face",
"palm_tree",
"maple_leaf",
"apple",
"skull",
"warning",
"jack_o_lantern",
"de-server-1",
"backups",
"cron-script",
"script-error",
"phils-automation",
"mouse",
"go-rocks",
"hi-ben",
]).slice(0, Math.round(Math.random() * 4));
const priority = shuffle([1, 2, 3, 4, 5])[0];
const title = shuffle([
"",
"",
"", // Higher chance of no title
"Oh my, another test message?",
"Titles are optional, did you know that?",
"ntfy is open source, and will always be free. Cool, right?",
"I don't really like apples",
"My favorite TV show is The Wire. You should watch it!",
"You can attach files and URLs to messages too",
"You can delay messages up to 3 days",
])[0];
const nowSeconds = Math.round(Date.now() / 1000);
const message = shuffle([
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
`Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`,
])[0];
try {
await api.publish(baseUrl, topic, message, {
title,
priority,
tags,
});
} catch (e) {
console.log(`[SubscriptionPopup] Error publishing message`, e);
setShowPublishError(true);
}
};
const handleClearAll = async () => {
console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`);
await subscriptionManager.deleteNotifications(props.subscription.id);
};
const handleUnsubscribe = async () => {
console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription);
await subscriptionManager.remove(props.subscription.id);
if (session.exists() && !subscription.internal) {
try {
await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic);
} catch (e) {
console.log(`[SubscriptionPopup] Error unsubscribing`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
const newSelected = await subscriptionManager.first(); // May be undefined
if (newSelected && !newSelected.internal) {
navigate(routes.forSubscription(newSelected));
} else {
navigate(routes.app);
}
};
return (
<>
<PopupMenu horizontal={placement} anchorEl={props.anchor} open={!!props.anchor} onClose={props.onClose}>
<MenuItem onClick={handleChangeDisplayName}>{t("action_bar_change_display_name")}</MenuItem>
{showReservationAdd && <MenuItem onClick={handleReserveAdd}>{t("action_bar_reservation_add")}</MenuItem>}
{showReservationAddDisabled && (
<MenuItem sx={{ cursor: "default" }}>
<span style={{ opacity: 0.3 }}>{t("action_bar_reservation_add")}</span>
<ReserveLimitChip />
</MenuItem>
)}
{showReservationEdit && <MenuItem onClick={handleReserveEdit}>{t("action_bar_reservation_edit")}</MenuItem>}
{showReservationDelete && <MenuItem onClick={handleReserveDelete}>{t("action_bar_reservation_delete")}</MenuItem>}
<MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
<MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
<MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
</PopupMenu>
<Portal>
<Snackbar
open={showPublishError}
autoHideDuration={3000}
onClose={() => setShowPublishError(false)}
message={t("message_bar_error_publishing")}
/>
<DisplayNameDialog open={displayNameDialogOpen} subscription={subscription} onClose={() => setDisplayNameDialogOpen(false)} />
{showReservationAdd && (
<ReserveAddDialog
open={reserveAddDialogOpen}
topic={subscription.topic}
reservations={reservations}
onClose={() => setReserveAddDialogOpen(false)}
/>
)}
{showReservationEdit && (
<ReserveEditDialog
open={reserveEditDialogOpen}
reservation={subscription.reservation}
reservations={props.reservations}
onClose={() => setReserveEditDialogOpen(false)}
/>
)}
{showReservationDelete && (
<ReserveDeleteDialog
open={reserveDeleteDialogOpen}
topic={subscription.topic}
onClose={() => setReserveDeleteDialogOpen(false)}
/>
)}
</Portal>
</>
);
};
const DisplayNameDialog = (props) => {
const { t } = useTranslation();
const { subscription } = props;
const [error, setError] = useState("");
const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSave = async () => {
await subscriptionManager.setDisplayName(subscription.id, displayName);
if (session.exists() && !subscription.internal) {
try {
console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`);
await accountApi.updateSubscription(subscription.baseUrl, subscription.topic, { display_name: displayName });
} catch (e) {
console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
}
}
}
props.onClose();
};
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{t("display_name_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("display_name_dialog_description")}</DialogContentText>
<TextField
autoFocus
placeholder={t("display_name_dialog_placeholder")}
value={displayName}
onChange={(ev) => setDisplayName(ev.target.value)}
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>
),
}}
/>
</DialogContent>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSave}>{t("common_save")}</Button>
</DialogFooter>
</Dialog>
);
};
export const ReserveLimitChip = () => {
const { account } = useContext(AccountContext);
if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) {
return <></>;
}
if (config.enable_payments) {
return account?.limits.reservations > 0 ? <LimitReachedChip /> : <ProChip />;
}
if (account) {
return <LimitReachedChip />;
}
return <></>;
};
const LimitReachedChip = () => {
const { t } = useTranslation();
return (
<Chip
label={t("action_bar_reservation_limit_reached")}
variant="outlined"
color="primary"
sx={{
opacity: 0.8,
borderWidth: "2px",
height: "24px",
marginLeft: "5px",
}}
/>
);
};
export const ProChip = () => (
<Chip
label="ntfy Pro"
variant="outlined"
color="primary"
sx={{
opacity: 0.8,
fontWeight: "bold",
borderWidth: "2px",
height: "24px",
marginLeft: "5px",
}}
/>
);

View File

@@ -1,367 +0,0 @@
import * as React from 'react';
import {useContext, useEffect, useState} from 'react';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import {Alert, CardActionArea, CardContent, Chip, Link, ListItem, Switch, useMediaQuery} from "@mui/material";
import theme from "./theme";
import Button from "@mui/material/Button";
import accountApi, {SubscriptionInterval} from "../app/AccountApi";
import session from "../app/Session";
import routes from "./routes";
import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography";
import {AccountContext} from "./App";
import {formatBytes, formatNumber, formatPrice, formatShortDate} from "../app/utils";
import {Trans, useTranslation} from "react-i18next";
import List from "@mui/material/List";
import {Check, Close} from "@mui/icons-material";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Box from "@mui/material/Box";
import {NavLink} from "react-router-dom";
import {UnauthorizedError} from "../app/errors";
import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions";
const UpgradeDialog = (props) => {
const { t } = useTranslation();
const { account } = useContext(AccountContext); // May be undefined!
const [error, setError] = useState("");
const [tiers, setTiers] = useState(null);
const [interval, setInterval] = useState(account?.billing?.interval || SubscriptionInterval.YEAR);
const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined
const [loading, setLoading] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
useEffect(() => {
const fetchTiers = async () => {
setTiers(await accountApi.billingTiers());
}
fetchTiers(); // Dangle
}, []);
if (!tiers) {
return <></>;
}
const tiersMap = Object.assign(...tiers.map(tier => ({[tier.code]: tier})));
const newTier = tiersMap[newTierCode]; // May be undefined
const currentTier = account?.tier; // May be undefined
const currentInterval = account?.billing?.interval; // May be undefined
const currentTierCode = currentTier?.code; // May be undefined
// Figure out buttons, labels and the submit action
let submitAction, submitButtonLabel, banner;
if (!account) {
submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
submitAction = Action.REDIRECT_SIGNUP;
banner = null;
} else if (currentTierCode === newTierCode && (currentInterval === undefined || currentInterval === interval)) {
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
submitAction = null;
banner = (currentTierCode) ? Banner.PRORATION_INFO : null;
} else if (!currentTierCode) {
submitButtonLabel = t("account_upgrade_dialog_button_pay_now");
submitAction = Action.CREATE_SUBSCRIPTION;
banner = null;
} else if (!newTierCode) {
submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription");
submitAction = Action.CANCEL_SUBSCRIPTION;
banner = Banner.CANCEL_WARNING;
} else {
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
submitAction = Action.UPDATE_SUBSCRIPTION;
banner = Banner.PRORATION_INFO;
}
// Exceptional conditions
if (loading) {
submitAction = null;
} else if (newTier?.code && account?.reservations?.length > newTier?.limits?.reservations) {
submitAction = null;
banner = Banner.RESERVATIONS_WARNING;
}
const handleSubmit = async () => {
if (submitAction === Action.REDIRECT_SIGNUP) {
window.location.href = routes.signup;
return;
}
try {
setLoading(true);
if (submitAction === Action.CREATE_SUBSCRIPTION) {
const response = await accountApi.createBillingSubscription(newTierCode, interval);
window.location.href = response.redirect_url;
} else if (submitAction === Action.UPDATE_SUBSCRIPTION) {
await accountApi.updateBillingSubscription(newTierCode, interval);
} else if (submitAction === Action.CANCEL_SUBSCRIPTION) {
await accountApi.deleteBillingSubscription();
}
props.onCancel();
} catch (e) {
console.log(`[UpgradeDialog] Error changing billing subscription`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
}
} finally {
setLoading(false);
}
}
// Figure out discount
let discount = 0, upto = false;
if (newTier?.prices) {
discount = Math.round(((newTier.prices.month*12/newTier.prices.year)-1)*100);
} else {
let n = 0;
for (const t of tiers) {
if (t.prices) {
const tierDiscount = Math.round(((t.prices.month*12/t.prices.year)-1)*100);
if (tierDiscount > discount) {
discount = tierDiscount;
n++;
}
}
}
upto = n > 1;
}
return (
<Dialog
open={props.open}
onClose={props.onCancel}
maxWidth="lg"
fullScreen={fullScreen}
>
<DialogTitle>
<div style={{ display: "flex", flexDirection: "row" }}>
<div style={{ flexGrow: 1 }}>{t("account_upgrade_dialog_title")}</div>
<div style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
marginTop: "4px"
}}>
<Typography component="span" variant="subtitle1">{t("account_upgrade_dialog_interval_monthly")}</Typography>
<Switch
checked={interval === SubscriptionInterval.YEAR}
onChange={(ev) => setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)}
/>
<Typography component="span" variant="subtitle1">{t("account_upgrade_dialog_interval_yearly")}</Typography>
{discount > 0 &&
<Chip
label={upto ? t("account_upgrade_dialog_interval_yearly_discount_save_up_to", { discount: discount }) : t("account_upgrade_dialog_interval_yearly_discount_save", { discount: discount })}
color="primary"
size="small"
variant={interval === SubscriptionInterval.YEAR ? "filled" : "outlined"}
sx={{ marginLeft: "5px" }}
/>
}
</div>
</div>
</DialogTitle>
<DialogContent>
<div style={{
display: "flex",
flexDirection: "row",
marginBottom: "8px",
width: "100%"
}}>
{tiers.map(tier =>
<TierCard
key={`tierCard${tier.code || '_free'}`}
tier={tier}
current={currentTierCode === tier.code} // tier.code or currentTierCode may be undefined!
selected={newTierCode === tier.code} // tier.code may be undefined!
interval={interval}
onClick={() => setNewTierCode(tier.code)} // tier.code may be undefined!
/>
)}
</div>
{banner === Banner.CANCEL_WARNING &&
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
<Trans
i18nKey="account_upgrade_dialog_cancel_warning"
values={{ date: formatShortDate(account?.billing?.paid_until || 0) }} />
</Alert>
}
{banner === Banner.PRORATION_INFO &&
<Alert severity="info" sx={{ fontSize: "1rem" }}>
<Trans i18nKey="account_upgrade_dialog_proration_info" />
</Alert>
}
{banner === Banner.RESERVATIONS_WARNING &&
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
<Trans
i18nKey="account_upgrade_dialog_reservations_warning"
count={account?.reservations.length - newTier?.limits.reservations}
components={{
Link: <NavLink to={routes.settings}/>,
}}
/>
</Alert>
}
</DialogContent>
<Box sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
paddingLeft: '24px',
paddingBottom: '8px',
}}>
<DialogContentText
component="div"
aria-live="polite"
sx={{
margin: '0px',
paddingTop: '12px',
paddingBottom: '4px'
}}
>
{config.billing_contact.indexOf('@') !== -1 &&
<><Trans i18nKey="account_upgrade_dialog_billing_contact_email" components={{ Link: <Link href={`mailto:${config.billing_contact}`}/> }}/>{" "}</>
}
{config.billing_contact.match(`^http?s://`) &&
<><Trans i18nKey="account_upgrade_dialog_billing_contact_website" components={{ Link: <Link href={config.billing_contact} target="_blank"/> }}/>{" "}</>
}
{error}
</DialogContentText>
<DialogActions sx={{paddingRight: 2}}>
<Button onClick={props.onCancel}>{t("account_upgrade_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!submitAction}>{submitButtonLabel}</Button>
</DialogActions>
</Box>
</Dialog>
);
};
const TierCard = (props) => {
const { t } = useTranslation();
const tier = props.tier;
let cardStyle, labelStyle, labelText;
if (props.selected) {
cardStyle = { background: "#eee", border: "3px solid #338574" };
labelStyle = { background: "#338574", color: "white" };
labelText = t("account_upgrade_dialog_tier_selected_label");
} else if (props.current) {
cardStyle = { border: "3px solid #eee" };
labelStyle = { background: "#eee", color: "black" };
labelText = t("account_upgrade_dialog_tier_current_label");
} else {
cardStyle = { border: "3px solid transparent" };
}
let monthlyPrice;
if (!tier.prices) {
monthlyPrice = 0;
} else if (props.interval === SubscriptionInterval.YEAR) {
monthlyPrice = tier.prices.year/12;
} else if (props.interval === SubscriptionInterval.MONTH) {
monthlyPrice = tier.prices.month;
}
return (
<Box sx={{
m: "7px",
minWidth: "240px",
flexGrow: 1,
flexShrink: 1,
flexBasis: 0,
borderRadius: "5px",
"&:first-of-type": { ml: 0 },
"&:last-of-type": { mr: 0 },
...cardStyle
}}>
<Card sx={{ height: "100%" }}>
<CardActionArea sx={{ height: "100%" }}>
<CardContent onClick={props.onClick} sx={{ height: "100%" }}>
{labelStyle &&
<div style={{
position: "absolute",
top: "0",
right: "15px",
padding: "2px 10px",
borderRadius: "3px",
...labelStyle
}}>{labelText}</div>
}
<Typography variant="subtitle1" component="div">
{tier.name || t("account_basics_tier_free")}
</Typography>
<div>
<Typography component="span" variant="h4" sx={{ fontWeight: 500, marginRight: "3px" }}>{formatPrice(monthlyPrice)}</Typography>
{monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}</>}
</div>
<List dense>
{tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>}
<Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature>
<Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature>
{tier.limits.calls > 0 && <Feature>{t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}</Feature>}
<Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature>
{tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
{tier.limits.calls === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_calls")}</NoFeature>}
</List>
{tier.prices && props.interval === SubscriptionInterval.MONTH &&
<Typography variant="body2" color="gray">
{t("account_upgrade_dialog_tier_price_billed_monthly", { price: formatPrice(tier.prices.month*12) })}
</Typography>
}
{tier.prices && props.interval === SubscriptionInterval.YEAR &&
<Typography variant="body2" color="gray">
{t("account_upgrade_dialog_tier_price_billed_yearly", { price: formatPrice(tier.prices.year), save: formatPrice(tier.prices.month*12-tier.prices.year) })}
</Typography>
}
</CardContent>
</CardActionArea>
</Card>
</Box>
);
}
const Feature = (props) => {
return <FeatureItem feature={true}>{props.children}</FeatureItem>;
}
const NoFeature = (props) => {
return <FeatureItem feature={false}>{props.children}</FeatureItem>;
}
const FeatureItem = (props) => {
return (
<ListItem disableGutters sx={{m: 0, p: 0}}>
<ListItemIcon sx={{minWidth: "24px"}}>
{props.feature && <Check fontSize="small" sx={{ color: "#338574" }}/>}
{!props.feature && <Close fontSize="small" sx={{ color: "gray" }}/>}
</ListItemIcon>
<ListItemText
sx={{mt: "2px", mb: "2px"}}
primary={
<Typography variant="body1">
{props.children}
</Typography>
}
/>
</ListItem>
);
};
const Action = {
REDIRECT_SIGNUP: 1,
CREATE_SUBSCRIPTION: 2,
UPDATE_SUBSCRIPTION: 3,
CANCEL_SUBSCRIPTION: 4
};
const Banner = {
CANCEL_WARNING: 1,
PRORATION_INFO: 2,
RESERVATIONS_WARNING: 3
};
export default UpgradeDialog;

View File

@@ -0,0 +1,435 @@
import * as React from "react";
import { useContext, useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogTitle,
Alert,
CardActionArea,
CardContent,
Chip,
Link,
ListItem,
Switch,
useMediaQuery,
Button,
Card,
Typography,
List,
ListItemIcon,
ListItemText,
Box,
DialogContentText,
DialogActions,
} from "@mui/material";
import { Trans, useTranslation } from "react-i18next";
import { Check, Close } from "@mui/icons-material";
import { NavLink } from "react-router-dom";
import { UnauthorizedError } from "../app/errors";
import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils";
import { AccountContext } from "./App";
import routes from "./routes";
import session from "../app/Session";
import accountApi, { SubscriptionInterval } from "../app/AccountApi";
import theme from "./theme";
const Feature = (props) => <FeatureItem feature>{props.children}</FeatureItem>;
const NoFeature = (props) => <FeatureItem feature={false}>{props.children}</FeatureItem>;
const FeatureItem = (props) => (
<ListItem disableGutters sx={{ m: 0, p: 0 }}>
<ListItemIcon sx={{ minWidth: "24px" }}>
{props.feature && <Check fontSize="small" sx={{ color: "#338574" }} />}
{!props.feature && <Close fontSize="small" sx={{ color: "gray" }} />}
</ListItemIcon>
<ListItemText sx={{ mt: "2px", mb: "2px" }} primary={<Typography variant="body1">{props.children}</Typography>} />
</ListItem>
);
const Action = {
REDIRECT_SIGNUP: 1,
CREATE_SUBSCRIPTION: 2,
UPDATE_SUBSCRIPTION: 3,
CANCEL_SUBSCRIPTION: 4,
};
const Banner = {
CANCEL_WARNING: 1,
PRORATION_INFO: 2,
RESERVATIONS_WARNING: 3,
};
const UpgradeDialog = (props) => {
const { t } = useTranslation();
const { account } = useContext(AccountContext); // May be undefined!
const [error, setError] = useState("");
const [tiers, setTiers] = useState(null);
const [interval, setInterval] = useState(account?.billing?.interval || SubscriptionInterval.YEAR);
const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined
const [loading, setLoading] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
useEffect(() => {
const fetchTiers = async () => {
setTiers(await accountApi.billingTiers());
};
fetchTiers(); // Dangle
}, []);
if (!tiers) {
return <></>;
}
const tiersMap = Object.assign(...tiers.map((tier) => ({ [tier.code]: tier })));
const newTier = tiersMap[newTierCode]; // May be undefined
const currentTier = account?.tier; // May be undefined
const currentInterval = account?.billing?.interval; // May be undefined
const currentTierCode = currentTier?.code; // May be undefined
// Figure out buttons, labels and the submit action
let submitAction;
let submitButtonLabel;
let banner;
if (!account) {
submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
submitAction = Action.REDIRECT_SIGNUP;
banner = null;
} else if (currentTierCode === newTierCode && (currentInterval === undefined || currentInterval === interval)) {
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
submitAction = null;
banner = currentTierCode ? Banner.PRORATION_INFO : null;
} else if (!currentTierCode) {
submitButtonLabel = t("account_upgrade_dialog_button_pay_now");
submitAction = Action.CREATE_SUBSCRIPTION;
banner = null;
} else if (!newTierCode) {
submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription");
submitAction = Action.CANCEL_SUBSCRIPTION;
banner = Banner.CANCEL_WARNING;
} else {
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
submitAction = Action.UPDATE_SUBSCRIPTION;
banner = Banner.PRORATION_INFO;
}
// Exceptional conditions
if (loading) {
submitAction = null;
} else if (newTier?.code && account?.reservations?.length > newTier?.limits?.reservations) {
submitAction = null;
banner = Banner.RESERVATIONS_WARNING;
}
const handleSubmit = async () => {
if (submitAction === Action.REDIRECT_SIGNUP) {
window.location.href = routes.signup;
return;
}
try {
setLoading(true);
if (submitAction === Action.CREATE_SUBSCRIPTION) {
const response = await accountApi.createBillingSubscription(newTierCode, interval);
window.location.href = response.redirect_url;
} else if (submitAction === Action.UPDATE_SUBSCRIPTION) {
await accountApi.updateBillingSubscription(newTierCode, interval);
} else if (submitAction === Action.CANCEL_SUBSCRIPTION) {
await accountApi.deleteBillingSubscription();
}
props.onCancel();
} catch (e) {
console.log(`[UpgradeDialog] Error changing billing subscription`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
}
} finally {
setLoading(false);
}
};
// Figure out discount
let discount = 0;
let upto = false;
if (newTier?.prices) {
discount = Math.round(((newTier.prices.month * 12) / newTier.prices.year - 1) * 100);
} else {
let n = 0;
for (const tier of tiers) {
if (tier.prices) {
const tierDiscount = Math.round(((tier.prices.month * 12) / tier.prices.year - 1) * 100);
if (tierDiscount > discount) {
discount = tierDiscount;
n += 1;
}
}
}
upto = n > 1;
}
return (
<Dialog open={props.open} onClose={props.onCancel} maxWidth="lg" fullScreen={fullScreen}>
<DialogTitle>
<div style={{ display: "flex", flexDirection: "row" }}>
<div style={{ flexGrow: 1 }}>{t("account_upgrade_dialog_title")}</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
marginTop: "4px",
}}
>
<Typography component="span" variant="subtitle1">
{t("account_upgrade_dialog_interval_monthly")}
</Typography>
<Switch
checked={interval === SubscriptionInterval.YEAR}
onChange={(ev) => setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)}
/>
<Typography component="span" variant="subtitle1">
{t("account_upgrade_dialog_interval_yearly")}
</Typography>
{discount > 0 && (
<Chip
label={
upto
? t("account_upgrade_dialog_interval_yearly_discount_save_up_to", { discount })
: t("account_upgrade_dialog_interval_yearly_discount_save", { discount })
}
color="primary"
size="small"
variant={interval === SubscriptionInterval.YEAR ? "filled" : "outlined"}
sx={{ marginLeft: "5px" }}
/>
)}
</div>
</div>
</DialogTitle>
<DialogContent>
<div
style={{
display: "flex",
flexDirection: "row",
marginBottom: "8px",
width: "100%",
}}
>
{tiers.map((tier) => (
<TierCard
key={`tierCard${tier.code || "_free"}`}
tier={tier}
current={currentTierCode === tier.code} // tier.code or currentTierCode may be undefined!
selected={newTierCode === tier.code} // tier.code may be undefined!
interval={interval}
onClick={() => setNewTierCode(tier.code)} // tier.code may be undefined!
/>
))}
</div>
{banner === Banner.CANCEL_WARNING && (
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
<Trans
i18nKey="account_upgrade_dialog_cancel_warning"
values={{
date: formatShortDate(account?.billing?.paid_until || 0),
}}
/>
</Alert>
)}
{banner === Banner.PRORATION_INFO && (
<Alert severity="info" sx={{ fontSize: "1rem" }}>
<Trans i18nKey="account_upgrade_dialog_proration_info" />
</Alert>
)}
{banner === Banner.RESERVATIONS_WARNING && (
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
<Trans
i18nKey="account_upgrade_dialog_reservations_warning"
count={(account?.reservations.length ?? 0) - (newTier?.limits.reservations ?? 0)}
components={{
Link: <NavLink to={routes.settings} />,
}}
/>
</Alert>
)}
</DialogContent>
<Box
sx={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
paddingLeft: "24px",
paddingBottom: "8px",
}}
>
<DialogContentText
component="div"
aria-live="polite"
sx={{
margin: "0px",
paddingTop: "12px",
paddingBottom: "4px",
}}
>
{config.billing_contact.indexOf("@") !== -1 && (
<>
<Trans
i18nKey="account_upgrade_dialog_billing_contact_email"
components={{
Link: <Link href={`mailto:${config.billing_contact}`} />,
}}
/>{" "}
</>
)}
{config.billing_contact.match(`^http?s://`) && (
<>
<Trans
i18nKey="account_upgrade_dialog_billing_contact_website"
components={{
Link: <Link href={config.billing_contact} target="_blank" />,
}}
/>{" "}
</>
)}
{error}
</DialogContentText>
<DialogActions sx={{ paddingRight: 2 }}>
<Button onClick={props.onCancel}>{t("account_upgrade_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!submitAction}>
{submitButtonLabel}
</Button>
</DialogActions>
</Box>
</Dialog>
);
};
const TierCard = (props) => {
const { t } = useTranslation();
const { tier } = props;
let cardStyle;
let labelStyle;
let labelText;
if (props.selected) {
cardStyle = { background: "#eee", border: "3px solid #338574" };
labelStyle = { background: "#338574", color: "white" };
labelText = t("account_upgrade_dialog_tier_selected_label");
} else if (props.current) {
cardStyle = { border: "3px solid #eee" };
labelStyle = { background: "#eee", color: "black" };
labelText = t("account_upgrade_dialog_tier_current_label");
} else {
cardStyle = { border: "3px solid transparent" };
}
let monthlyPrice;
if (!tier.prices) {
monthlyPrice = 0;
} else if (props.interval === SubscriptionInterval.YEAR) {
monthlyPrice = tier.prices.year / 12;
} else if (props.interval === SubscriptionInterval.MONTH) {
monthlyPrice = tier.prices.month;
}
return (
<Box
sx={{
m: "7px",
minWidth: "240px",
flexGrow: 1,
flexShrink: 1,
flexBasis: 0,
borderRadius: "5px",
"&:first-of-type": { ml: 0 },
"&:last-of-type": { mr: 0 },
...cardStyle,
}}
>
<Card sx={{ height: "100%" }}>
<CardActionArea sx={{ height: "100%" }}>
<CardContent onClick={props.onClick} sx={{ height: "100%" }}>
{labelStyle && (
<div
style={{
position: "absolute",
top: "0",
right: "15px",
padding: "2px 10px",
borderRadius: "3px",
...labelStyle,
}}
>
{labelText}
</div>
)}
<Typography variant="subtitle1" component="div">
{tier.name || t("account_basics_tier_free")}
</Typography>
<div>
<Typography component="span" variant="h4" sx={{ fontWeight: 500, marginRight: "3px" }}>
{formatPrice(monthlyPrice)}
</Typography>
{monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}</>}
</div>
<List dense>
{tier.limits.reservations > 0 && (
<Feature>
{t("account_upgrade_dialog_tier_features_reservations", {
reservations: tier.limits.reservations,
count: tier.limits.reservations,
})}
</Feature>
)}
<Feature>
{t("account_upgrade_dialog_tier_features_messages", {
messages: formatNumber(tier.limits.messages),
count: tier.limits.messages,
})}
</Feature>
<Feature>
{t("account_upgrade_dialog_tier_features_emails", {
emails: formatNumber(tier.limits.emails),
count: tier.limits.emails,
})}
</Feature>
{tier.limits.calls > 0 && (
<Feature>
{t("account_upgrade_dialog_tier_features_calls", {
calls: formatNumber(tier.limits.calls),
count: tier.limits.calls,
})}
</Feature>
)}
<Feature>
{t("account_upgrade_dialog_tier_features_attachment_file_size", {
filesize: formatBytes(tier.limits.attachment_file_size, 0),
})}
</Feature>
{tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
{tier.limits.calls === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_calls")}</NoFeature>}
</List>
{tier.prices && props.interval === SubscriptionInterval.MONTH && (
<Typography variant="body2" color="gray">
{t("account_upgrade_dialog_tier_price_billed_monthly", {
price: formatPrice(tier.prices.month * 12),
})}
</Typography>
)}
{tier.prices && props.interval === SubscriptionInterval.YEAR && (
<Typography variant="body2" color="gray">
{t("account_upgrade_dialog_tier_price_billed_yearly", {
price: formatPrice(tier.prices.year),
save: formatPrice(tier.prices.month * 12 - tier.prices.year),
})}
</Typography>
)}
</CardContent>
</CardActionArea>
</Card>
</Box>
);
};
export default UpgradeDialog;

View File

@@ -1,7 +1,7 @@
import {useNavigate, useParams} from "react-router-dom";
import {useEffect, useState} from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useState } from "react";
import subscriptionManager from "../app/SubscriptionManager";
import {disallowedTopic, expandSecureUrl, topicUrl} from "../app/utils";
import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils";
import notifier from "../app/Notifier";
import routes from "./routes";
import connectionManager from "../app/ConnectionManager";
@@ -9,7 +9,7 @@ import poller from "../app/Poller";
import pruner from "../app/Pruner";
import session from "../app/Session";
import accountApi from "../app/AccountApi";
import {UnauthorizedError} from "../app/errors";
import { UnauthorizedError } from "../app/errors";
/**
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@@ -17,65 +17,68 @@ import {UnauthorizedError} from "../app/errors";
* to the connection being re-established).
*/
export const useConnectionListeners = (account, subscriptions, users) => {
const navigate = useNavigate();
const navigate = useNavigate();
// Register listeners for incoming messages, and connection state changes
useEffect(() => {
const handleMessage = async (subscriptionId, message) => {
const subscription = await subscriptionManager.get(subscriptionId);
if (subscription.internal) {
await handleInternalMessage(message);
} else {
await handleNotification(subscriptionId, message);
}
};
const handleInternalMessage = async (message) => {
console.log(`[ConnectionListener] Received message on sync topic`, message.message);
try {
const data = JSON.parse(message.message);
if (data.event === "sync") {
console.log(`[ConnectionListener] Triggering account sync`);
await accountApi.sync();
} else {
console.log(`[ConnectionListener] Unknown message type. Doing nothing.`);
}
} catch (e) {
console.log(`[ConnectionListener] Error parsing sync topic message`, e);
}
};
const handleNotification = async (subscriptionId, notification) => {
const added = await subscriptionManager.addNotification(subscriptionId, notification);
if (added) {
const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription));
await notifier.notify(subscriptionId, notification, defaultClickAction)
}
};
connectionManager.registerStateListener(subscriptionManager.updateState);
connectionManager.registerMessageListener(handleMessage);
return () => {
connectionManager.resetStateListener();
connectionManager.resetMessageListener();
}
},
// We have to disable dep checking for "navigate". This is fine, it never changes.
// eslint-disable-next-line
[]
);
// Sync topic listener: For accounts with sync_topic, subscribe to an internal topic
useEffect(() => {
if (!account || !account.sync_topic) {
return;
// Register listeners for incoming messages, and connection state changes
useEffect(
() => {
const handleInternalMessage = async (message) => {
console.log(`[ConnectionListener] Received message on sync topic`, message.message);
try {
const data = JSON.parse(message.message);
if (data.event === "sync") {
console.log(`[ConnectionListener] Triggering account sync`);
await accountApi.sync();
} else {
console.log(`[ConnectionListener] Unknown message type. Doing nothing.`);
}
} catch (e) {
console.log(`[ConnectionListener] Error parsing sync topic message`, e);
}
subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle!
}, [account]);
};
// When subscriptions or users change, refresh the connections
useEffect(() => {
connectionManager.refresh(subscriptions, users); // Dangle
}, [subscriptions, users]);
const handleNotification = async (subscriptionId, notification) => {
const added = await subscriptionManager.addNotification(subscriptionId, notification);
if (added) {
const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription));
await notifier.notify(subscriptionId, notification, defaultClickAction);
}
};
const handleMessage = async (subscriptionId, message) => {
const subscription = await subscriptionManager.get(subscriptionId);
if (subscription.internal) {
await handleInternalMessage(message);
} else {
await handleNotification(subscriptionId, message);
}
};
connectionManager.registerStateListener(subscriptionManager.updateState);
connectionManager.registerMessageListener(handleMessage);
return () => {
connectionManager.resetStateListener();
connectionManager.resetMessageListener();
};
},
// We have to disable dep checking for "navigate". This is fine, it never changes.
[]
);
// Sync topic listener: For accounts with sync_topic, subscribe to an internal topic
useEffect(() => {
if (!account || !account.sync_topic) {
return;
}
subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle!
}, [account]);
// When subscriptions or users change, refresh the connections
useEffect(() => {
connectionManager.refresh(subscriptions, users); // Dangle
}, [subscriptions, users]);
};
/**
@@ -83,35 +86,35 @@ export const useConnectionListeners = (account, subscriptions, users) => {
* This will only be run once after the initial page load.
*/
export const useAutoSubscribe = (subscriptions, selected) => {
const [hasRun, setHasRun] = useState(false);
const params = useParams();
const [hasRun, setHasRun] = useState(false);
const params = useParams();
useEffect(() => {
const loaded = subscriptions !== null && subscriptions !== undefined;
if (!loaded || hasRun) {
return;
useEffect(() => {
const loaded = subscriptions !== null && subscriptions !== undefined;
if (!loaded || hasRun) {
return;
}
setHasRun(true);
const eligible = params.topic && !selected && !disallowedTopic(params.topic);
if (eligible) {
const baseUrl = params.baseUrl ? expandSecureUrl(params.baseUrl) : config.base_url;
console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
(async () => {
const subscription = await subscriptionManager.add(baseUrl, params.topic);
if (session.exists()) {
try {
await accountApi.addSubscription(baseUrl, params.topic);
} catch (e) {
console.log(`[Hooks] Auto-subscribing failed`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
setHasRun(true);
const eligible = params.topic && !selected && !disallowedTopic(params.topic);
if (eligible) {
const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : config.base_url;
console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
(async () => {
const subscription = await subscriptionManager.add(baseUrl, params.topic);
if (session.exists()) {
try {
await accountApi.addSubscription(baseUrl, params.topic);
} catch (e) {
console.log(`[Hooks] Auto-subscribing failed`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
poller.pollInBackground(subscription); // Dangle!
})();
}
}, [params, subscriptions, selected, hasRun]);
poller.pollInBackground(subscription); // Dangle!
})();
}
}, [params, subscriptions, selected, hasRun]);
};
/**
@@ -120,19 +123,19 @@ export const useAutoSubscribe = (subscriptions, selected) => {
* up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186.
*/
export const useBackgroundProcesses = () => {
useEffect(() => {
poller.startWorker();
pruner.startWorker();
accountApi.startWorker();
}, []);
}
useEffect(() => {
poller.startWorker();
pruner.startWorker();
accountApi.startWorker();
}, []);
};
export const useAccountListener = (setAccount) => {
useEffect(() => {
accountApi.registerListener(setAccount);
accountApi.sync(); // Dangle
return () => {
accountApi.resetListener();
}
}, []);
}
useEffect(() => {
accountApi.registerListener(setAccount);
accountApi.sync(); // Dangle
return () => {
accountApi.resetListener();
};
}, []);
};

View File

@@ -1,29 +0,0 @@
import i18n from 'i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
// Translations using i18next
// - Options: https://www.i18next.com/overview/configuration-options
// - Browser Language Detector: https://github.com/i18next/i18next-browser-languageDetector
// - HTTP Backend (load files via fetch): https://github.com/i18next/i18next-http-backend
//
// See example project here:
// https://github.com/i18next/react-i18next/tree/master/example/react
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
debug: true,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
backend: {
loadPath: '/static/langs/{{lng}}.json',
}
});
export default i18n;

View File

@@ -0,0 +1,29 @@
import i18n from "i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
// Translations using i18next
// - Options: https://www.i18next.com/overview/configuration-options
// - Browser Language Detector: https://github.com/i18next/i18next-browser-languageDetector
// - HTTP Backend (load files via fetch): https://github.com/i18next/i18next-http-backend
//
// See example project here:
// https://github.com/i18next/react-i18next/tree/master/example/react
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en",
debug: true,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
backend: {
loadPath: "/static/langs/{{lng}}.json",
},
});
export default i18n;

View File

@@ -1,20 +1,20 @@
import config from "../app/config";
import {shortUrl} from "../app/utils";
import { shortUrl } from "../app/utils";
const routes = {
login: "/login",
signup: "/signup",
app: config.app_root,
account: "/account",
settings: "/settings",
subscription: "/:topic",
subscriptionExternal: "/:baseUrl/:topic",
forSubscription: (subscription) => {
if (subscription.baseUrl !== config.base_url) {
return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;
}
return `/${subscription.topic}`;
login: "/login",
signup: "/signup",
app: config.app_root,
account: "/account",
settings: "/settings",
subscription: "/:topic",
subscriptionExternal: "/:baseUrl/:topic",
forSubscription: (subscription) => {
if (subscription.baseUrl !== config.base_url) {
return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;
}
return `/${subscription.topic}`;
},
};
export default routes;

View File

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

View File

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

View File

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

6
web/src/index.jsx Normal file
View File

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

14
web/vite.config.js Normal file
View File

@@ -0,0 +1,14 @@
/* eslint-disable import/no-extraneous-dependencies */
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(() => ({
build: {
outDir: "build",
assetsDir: "static/media",
},
server: {
port: 3000,
},
plugins: [react()],
}));