Compare commits
58 Commits
v2.5.0
...
web-improv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2697600111 | ||
|
|
c16da26780 | ||
|
|
c50633d990 | ||
|
|
517341b5d7 | ||
|
|
e1dd0c64e2 | ||
|
|
e7bf165934 | ||
|
|
a90bd4cd06 | ||
|
|
d1e59fe08c | ||
|
|
6bb5274d83 | ||
|
|
b7c121e78e | ||
|
|
1251a4adab | ||
|
|
4cacc02520 | ||
|
|
025ea3c1d6 | ||
|
|
be6d962cc3 | ||
|
|
4a0a22566a | ||
|
|
25d6725d8f | ||
|
|
1291c3afe9 | ||
|
|
d625a003b8 | ||
|
|
e21327cec5 | ||
|
|
7ccc5be9b4 | ||
|
|
9ebeb7f12f | ||
|
|
d3be1fa359 | ||
|
|
e3d530cb90 | ||
|
|
951c90763a | ||
|
|
59011c8a32 | ||
|
|
8319f1cf26 | ||
|
|
f558b4dbe9 | ||
|
|
d7eb1206fe | ||
|
|
fa29da1a32 | ||
|
|
a64e365add | ||
|
|
c87549e71a | ||
|
|
ca5d736a71 | ||
|
|
2e27f58963 | ||
|
|
6f230a796e | ||
|
|
9e44db78a2 | ||
|
|
a859ed9f58 | ||
|
|
6f6a2d1f69 | ||
|
|
206ea312bf | ||
|
|
3f8784c8a8 | ||
|
|
1761ec0207 | ||
|
|
ceedca4e27 | ||
|
|
ffbf288c9b | ||
|
|
f8a00dd411 | ||
|
|
6a5b5b3763 | ||
|
|
6bd4c8fb71 | ||
|
|
df2872bebd | ||
|
|
0393145f42 | ||
|
|
da06ae4485 | ||
|
|
e10442f6ca | ||
|
|
5379474c41 | ||
|
|
168ad8bf1b | ||
|
|
89cf84b63e | ||
|
|
b3a299ce22 | ||
|
|
7838b253b4 | ||
|
|
7140f18574 | ||
|
|
5345b9063c | ||
|
|
4ad0fb1f57 | ||
|
|
57eabd3aa5 |
11
.git-blame-ignore-revs
Normal file
11
.git-blame-ignore-revs
Normal 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
|
||||
23
.github/workflows/build.yaml
vendored
23
.github/workflows/build.yaml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/docs.yaml
vendored
2
.github/workflows/docs.yaml
vendored
@@ -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
|
||||
|
||||
23
.github/workflows/release.yaml
vendored
23
.github/workflows/release.yaml
vendored
@@ -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
|
||||
|
||||
23
.github/workflows/test.yaml
vendored
23
.github/workflows/test.yaml
vendored
@@ -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
|
||||
|
||||
15
Makefile
15
Makefile
@@ -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)')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)).
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
1
web/.eslintignore
Normal file
@@ -0,0 +1 @@
|
||||
src/app/emojis.js
|
||||
37
web/.eslintrc
Normal file
37
web/.eslintrc
Normal 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
4
web/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
build/
|
||||
dist/
|
||||
public/static/langs/
|
||||
src/app/emojis.js
|
||||
49
web/index.html
Normal file
49
web/index.html
Normal 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
14464
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "電話"
|
||||
}
|
||||
|
||||
@@ -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": "Оплатити зараз і підписатися"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
14499
web/src/app/emojis.js
14499
web/src/app/emojis.js
File diff suppressed because one or more lines are too long
@@ -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!
|
||||
};
|
||||
|
||||
@@ -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
1128
web/src/components/Account.jsx
Normal file
1128
web/src/components/Account.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
172
web/src/components/ActionBar.jsx
Normal file
172
web/src/components/ActionBar.jsx
Normal 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;
|
||||
@@ -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
140
web/src/components/App.jsx
Normal 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;
|
||||
@@ -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;
|
||||
48
web/src/components/AttachmentIcon.jsx
Normal file
48
web/src/components/AttachmentIcon.jsx
Normal 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;
|
||||
@@ -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;
|
||||
22
web/src/components/AvatarBox.jsx
Normal file
22
web/src/components/AvatarBox.jsx
Normal 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;
|
||||
@@ -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;
|
||||
29
web/src/components/DialogFooter.jsx
Normal file
29
web/src/components/DialogFooter.jsx
Normal 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;
|
||||
@@ -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;
|
||||
158
web/src/components/EmojiPicker.jsx
Normal file
158
web/src/components/EmojiPicker.jsx
Normal 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;
|
||||
@@ -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;
|
||||
134
web/src/components/ErrorBoundary.jsx
Normal file
134
web/src/components/ErrorBoundary.jsx
Normal 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;
|
||||
@@ -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;
|
||||
117
web/src/components/Login.jsx
Normal file
117
web/src/components/Login.jsx
Normal 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;
|
||||
@@ -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;
|
||||
108
web/src/components/Messaging.jsx
Normal file
108
web/src/components/Messaging.jsx
Normal 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;
|
||||
@@ -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;
|
||||
396
web/src/components/Navigation.jsx
Normal file
396
web/src/components/Navigation.jsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
616
web/src/components/Notifications.jsx
Normal file
616
web/src/components/Notifications.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
48
web/src/components/PopupMenu.jsx
Normal file
48
web/src/components/PopupMenu.jsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
52
web/src/components/Pref.jsx
Normal file
52
web/src/components/Pref.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
695
web/src/components/Preferences.jsx
Normal file
695
web/src/components/Preferences.jsx
Normal 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;
|
||||
@@ -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;
|
||||
913
web/src/components/PublishDialog.jsx
Normal file
913
web/src/components/PublishDialog.jsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
199
web/src/components/ReserveDialogs.jsx
Normal file
199
web/src/components/ReserveDialogs.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
47
web/src/components/ReserveIcons.jsx
Normal file
47
web/src/components/ReserveIcons.jsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
54
web/src/components/ReserveTopicSelect.jsx
Normal file
54
web/src/components/ReserveTopicSelect.jsx
Normal 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;
|
||||
@@ -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;
|
||||
153
web/src/components/Signup.jsx
Normal file
153
web/src/components/Signup.jsx
Normal 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;
|
||||
@@ -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;
|
||||
320
web/src/components/SubscribeDialog.jsx
Normal file
320
web/src/components/SubscribeDialog.jsx
Normal 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;
|
||||
@@ -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" }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
314
web/src/components/SubscriptionPopup.jsx
Normal file
314
web/src/components/SubscriptionPopup.jsx
Normal 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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -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;
|
||||
435
web/src/components/UpgradeDialog.jsx
Normal file
435
web/src/components/UpgradeDialog.jsx
Normal 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;
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
29
web/src/components/i18n.jsx
Normal file
29
web/src/components/i18n.jsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
6
web/src/index.jsx
Normal 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
14
web/vite.config.js
Normal 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()],
|
||||
}));
|
||||
Reference in New Issue
Block a user