Actually apply the pre-commit fixers to the codebase.
This can be redone manually with `pre-commit run --all` While the pre-commit hook could be merged to run locally, it is much cleaner to align all the files to best-practice syntax in a single commit. It is also required for server-side validation.
This commit is contained in:
parent
108ad3c7c3
commit
b218e62ffc
151 changed files with 42251 additions and 31034 deletions
25
.github/workflows/build.yaml
vendored
25
.github/workflows/build.yaml
vendored
|
@ -4,21 +4,17 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
- name: Install Go
|
||||||
name: Install Go
|
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.18.x'
|
go-version: "1.18.x"
|
||||||
-
|
- name: Install node
|
||||||
name: Install node
|
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '17'
|
node-version: "17"
|
||||||
-
|
- name: Checkout code
|
||||||
name: Checkout code
|
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
-
|
- name: Cache Go and npm modules
|
||||||
name: Cache Go and npm modules
|
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
|
@ -28,12 +24,9 @@ jobs:
|
||||||
web/node_modules
|
web/node_modules
|
||||||
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||||
restore-keys: ${{ runner.os }}-ntfy-
|
restore-keys: ${{ runner.os }}-ntfy-
|
||||||
-
|
- name: Install dependencies
|
||||||
name: Install dependencies
|
|
||||||
run: make build-deps-ubuntu
|
run: make build-deps-ubuntu
|
||||||
-
|
- name: Build all the things
|
||||||
name: Build all the things
|
|
||||||
run: make build
|
run: make build
|
||||||
-
|
- name: Print build results and checksums
|
||||||
name: Print build results and checksums
|
|
||||||
run: make cli-build-results
|
run: make cli-build-results
|
||||||
|
|
15
.github/workflows/docs.yaml
vendored
15
.github/workflows/docs.yaml
vendored
|
@ -7,11 +7,9 @@ jobs:
|
||||||
publish-docs:
|
publish-docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
- name: Checkout ntfy code
|
||||||
name: Checkout ntfy code
|
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
-
|
- name: Checkout docs pages code
|
||||||
name: Checkout docs pages code
|
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
repository: binwiederhier/ntfy-docs.github.io
|
repository: binwiederhier/ntfy-docs.github.io
|
||||||
|
@ -19,14 +17,11 @@ jobs:
|
||||||
token: ${{secrets.NTFY_DOCS_PUSH_TOKEN}}
|
token: ${{secrets.NTFY_DOCS_PUSH_TOKEN}}
|
||||||
# Expires after 1 year, re-generate via
|
# Expires after 1 year, re-generate via
|
||||||
# User -> Settings -> Developer options -> Personal Access Tokens -> Fine Grained Token
|
# User -> Settings -> Developer options -> Personal Access Tokens -> Fine Grained Token
|
||||||
-
|
- name: Build docs
|
||||||
name: Build docs
|
|
||||||
run: make docs
|
run: make docs
|
||||||
-
|
- name: Copy generated docs
|
||||||
name: Copy generated docs
|
|
||||||
run: rsync -av --exclude CNAME --delete server/docs/ build/ntfy-docs.github.io/docs/
|
run: rsync -av --exclude CNAME --delete server/docs/ build/ntfy-docs.github.io/docs/
|
||||||
-
|
- name: Publish docs
|
||||||
name: Publish docs
|
|
||||||
run: |
|
run: |
|
||||||
cd build/ntfy-docs.github.io
|
cd build/ntfy-docs.github.io
|
||||||
git config user.name "GitHub Actions Bot"
|
git config user.name "GitHub Actions Bot"
|
||||||
|
|
30
.github/workflows/release.yaml
vendored
30
.github/workflows/release.yaml
vendored
|
@ -2,26 +2,22 @@ name: release
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
- name: Install Go
|
||||||
name: Install Go
|
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.18.x'
|
go-version: "1.18.x"
|
||||||
-
|
- name: Install node
|
||||||
name: Install node
|
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '17'
|
node-version: "17"
|
||||||
-
|
- name: Checkout code
|
||||||
name: Checkout code
|
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
-
|
- name: Cache Go and npm modules
|
||||||
name: Cache Go and npm modules
|
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
|
@ -31,20 +27,16 @@ jobs:
|
||||||
web/node_modules
|
web/node_modules
|
||||||
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||||
restore-keys: ${{ runner.os }}-ntfy-
|
restore-keys: ${{ runner.os }}-ntfy-
|
||||||
-
|
- name: Docker login
|
||||||
name: Docker login
|
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
-
|
- name: Install dependencies
|
||||||
name: Install dependencies
|
|
||||||
run: make build-deps-ubuntu
|
run: make build-deps-ubuntu
|
||||||
-
|
- name: Build and publish
|
||||||
name: Build and publish
|
|
||||||
run: make release
|
run: make release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
-
|
- name: Print build results and checksums
|
||||||
name: Print build results and checksums
|
|
||||||
run: make cli-build-results
|
run: make cli-build-results
|
||||||
|
|
34
.github/workflows/test.yaml
vendored
34
.github/workflows/test.yaml
vendored
|
@ -4,21 +4,17 @@ jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
- name: Install Go
|
||||||
name: Install Go
|
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.18.x'
|
go-version: "1.18.x"
|
||||||
-
|
- name: Install node
|
||||||
name: Install node
|
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '17'
|
node-version: "17"
|
||||||
-
|
- name: Checkout code
|
||||||
name: Checkout code
|
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
-
|
- name: Cache Go and npm modules
|
||||||
name: Cache Go and npm modules
|
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
|
@ -28,21 +24,15 @@ jobs:
|
||||||
web/node_modules
|
web/node_modules
|
||||||
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||||
restore-keys: ${{ runner.os }}-ntfy-
|
restore-keys: ${{ runner.os }}-ntfy-
|
||||||
-
|
- name: Install dependencies
|
||||||
name: Install dependencies
|
|
||||||
run: make build-deps-ubuntu
|
run: make build-deps-ubuntu
|
||||||
-
|
- name: Build docs (required for tests)
|
||||||
name: Build docs (required for tests)
|
|
||||||
run: make docs
|
run: make docs
|
||||||
-
|
- name: Build web app (required for tests)
|
||||||
name: Build web app (required for tests)
|
|
||||||
run: make web
|
run: make web
|
||||||
-
|
- name: Run tests, formatting, vetting and linting
|
||||||
name: Run tests, formatting, vetting and linting
|
|
||||||
run: make check
|
run: make check
|
||||||
-
|
- name: Run coverage
|
||||||
name: Run coverage
|
|
||||||
run: make coverage
|
run: make coverage
|
||||||
-
|
- name: Upload coverage to codecov.io
|
||||||
name: Upload coverage to codecov.io
|
|
||||||
run: make coverage-upload
|
run: make coverage-upload
|
||||||
|
|
|
@ -3,8 +3,7 @@ before:
|
||||||
- go mod download
|
- go mod download
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
builds:
|
builds:
|
||||||
-
|
- id: ntfy_linux_amd64
|
||||||
id: ntfy_linux_amd64
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
|
@ -16,8 +15,7 @@ builds:
|
||||||
hooks:
|
hooks:
|
||||||
post:
|
post:
|
||||||
- upx "{{ .Path }}" # apt install upx
|
- upx "{{ .Path }}" # apt install upx
|
||||||
-
|
- id: ntfy_linux_armv6
|
||||||
id: ntfy_linux_armv6
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
|
@ -29,8 +27,7 @@ builds:
|
||||||
goarch: [arm]
|
goarch: [arm]
|
||||||
goarm: [6]
|
goarm: [6]
|
||||||
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||||
-
|
- id: ntfy_linux_armv7
|
||||||
id: ntfy_linux_armv7
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
|
@ -42,8 +39,7 @@ builds:
|
||||||
goarch: [arm]
|
goarch: [arm]
|
||||||
goarm: [7]
|
goarm: [7]
|
||||||
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||||
-
|
- id: ntfy_linux_arm64
|
||||||
id: ntfy_linux_arm64
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
|
@ -54,8 +50,7 @@ builds:
|
||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [arm64]
|
goarch: [arm64]
|
||||||
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||||
-
|
- id: ntfy_windows_amd64
|
||||||
id: ntfy_windows_amd64
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||||
|
@ -65,8 +60,7 @@ builds:
|
||||||
goos: [windows]
|
goos: [windows]
|
||||||
goarch: [amd64]
|
goarch: [amd64]
|
||||||
# No "upx" for Windows to hopefully avoid Virus warnings
|
# No "upx" for Windows to hopefully avoid Virus warnings
|
||||||
-
|
- id: ntfy_darwin_all
|
||||||
id: ntfy_darwin_all
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||||
|
@ -76,8 +70,7 @@ builds:
|
||||||
goos: [darwin]
|
goos: [darwin]
|
||||||
goarch: [amd64, arm64] # will be combined to "universal binary" (see below)
|
goarch: [amd64, arm64] # will be combined to "universal binary" (see below)
|
||||||
nfpms:
|
nfpms:
|
||||||
-
|
- package_name: ntfy
|
||||||
package_name: ntfy
|
|
||||||
homepage: https://heckel.io/ntfy
|
homepage: https://heckel.io/ntfy
|
||||||
maintainer: Philipp C. Heckel <philipp.heckel@gmail.com>
|
maintainer: Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||||
description: Simple pub-sub notification service
|
description: Simple pub-sub notification service
|
||||||
|
@ -111,8 +104,7 @@ nfpms:
|
||||||
preremove: "scripts/prerm.sh"
|
preremove: "scripts/prerm.sh"
|
||||||
postremove: "scripts/postrm.sh"
|
postremove: "scripts/postrm.sh"
|
||||||
archives:
|
archives:
|
||||||
-
|
- id: ntfy_linux
|
||||||
id: ntfy_linux
|
|
||||||
builds:
|
builds:
|
||||||
- ntfy_linux_amd64
|
- ntfy_linux_amd64
|
||||||
- ntfy_linux_armv6
|
- ntfy_linux_armv6
|
||||||
|
@ -128,8 +120,7 @@ archives:
|
||||||
- client/ntfy-client.service
|
- client/ntfy-client.service
|
||||||
replacements:
|
replacements:
|
||||||
amd64: x86_64
|
amd64: x86_64
|
||||||
-
|
- id: ntfy_windows
|
||||||
id: ntfy_windows
|
|
||||||
builds:
|
builds:
|
||||||
- ntfy_windows_amd64
|
- ntfy_windows_amd64
|
||||||
format: zip
|
format: zip
|
||||||
|
@ -140,8 +131,7 @@ archives:
|
||||||
- client/client.yml
|
- client/client.yml
|
||||||
replacements:
|
replacements:
|
||||||
amd64: x86_64
|
amd64: x86_64
|
||||||
-
|
- id: ntfy_darwin
|
||||||
id: ntfy_darwin
|
|
||||||
builds:
|
builds:
|
||||||
- ntfy_darwin_all
|
- ntfy_darwin_all
|
||||||
wrap_in_directory: true
|
wrap_in_directory: true
|
||||||
|
@ -152,20 +142,19 @@ archives:
|
||||||
replacements:
|
replacements:
|
||||||
darwin: macOS
|
darwin: macOS
|
||||||
universal_binaries:
|
universal_binaries:
|
||||||
-
|
- id: ntfy_darwin_all
|
||||||
id: ntfy_darwin_all
|
|
||||||
replace: true
|
replace: true
|
||||||
name_template: ntfy
|
name_template: ntfy
|
||||||
checksum:
|
checksum:
|
||||||
name_template: 'checksums.txt'
|
name_template: "checksums.txt"
|
||||||
snapshot:
|
snapshot:
|
||||||
name_template: "{{ .Tag }}-next"
|
name_template: "{{ .Tag }}-next"
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
filters:
|
filters:
|
||||||
exclude:
|
exclude:
|
||||||
- '^docs:'
|
- "^docs:"
|
||||||
- '^test:'
|
- "^test:"
|
||||||
dockers:
|
dockers:
|
||||||
- image_templates:
|
- image_templates:
|
||||||
- &amd64_image "binwiederhier/ntfy:{{ .Tag }}-amd64"
|
- &amd64_image "binwiederhier/ntfy:{{ .Tag }}-amd64"
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: "v2.7.1"
|
rev: "v3.0.0-alpha.4"
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
|
exclude_types: [markdown]
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.4.0
|
rev: v4.4.0
|
||||||
hooks:
|
hooks:
|
||||||
|
@ -12,14 +13,13 @@ repos:
|
||||||
stages: ["commit"]
|
stages: ["commit"]
|
||||||
|
|
||||||
- repo: https://github.com/Bahjat/pre-commit-golang
|
- repo: https://github.com/Bahjat/pre-commit-golang
|
||||||
rev: v1.0.2
|
rev: v1.0.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: go-fmt-import
|
- id: go-fmt-import
|
||||||
- id: go-vet
|
- id: go-vet
|
||||||
- id: go-lint
|
- id: go-lint
|
||||||
- id: go-unit-tests
|
- id: go-unit-tests
|
||||||
stages: ["push"]
|
stages: ["push"]
|
||||||
- id: gofumpt # requires github.com/mvdan/gofumpt
|
|
||||||
- id: golangci-lint # requires github.com/golangci/golangci-lint
|
- id: golangci-lint # requires github.com/golangci/golangci-lint
|
||||||
args: [--config=.github/linters/.golangci.yml] # optional
|
args: [--config=.github/linters/.golangci.yml] # optional
|
||||||
- id: go-ruleguard # requires https://github.com/quasilyte/go-ruleguard
|
- id: go-ruleguard # requires https://github.com/quasilyte/go-ruleguard
|
||||||
|
|
|
@ -130,4 +130,3 @@ For answers to common questions about this code of conduct, see the FAQ at
|
||||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
[FAQ]: https://www.contributor-covenant.org/faq
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
[translations]: https://www.contributor-covenant.org/translations
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
|
|
||||||
|
|
|
@ -337,4 +337,3 @@ proprietary programs. If your program is a subroutine library, you may
|
||||||
consider it more useful to permit linking proprietary applications with the
|
consider it more useful to permit linking proprietary applications with the
|
||||||
library. If this is what you want to do, use the GNU Lesser General
|
library. If this is what you want to do, use the GNU Lesser General
|
||||||
Public License instead of this License.
|
Public License instead of this License.
|
||||||
|
|
||||||
|
|
|
@ -4,9 +4,10 @@ import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
package auth_test
|
package auth_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"heckel.io/ntfy/auth"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources
|
const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources
|
||||||
|
|
|
@ -7,13 +7,14 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event type constants
|
// Event type constants
|
||||||
|
|
|
@ -2,11 +2,12 @@ package client_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/client"
|
"heckel.io/ntfy/client"
|
||||||
"heckel.io/ntfy/test"
|
"heckel.io/ntfy/test"
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClient_Publish_Subscribe(t *testing.T) {
|
func TestClient_Publish_Subscribe(t *testing.T) {
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gopkg.in/yaml.v2"
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
package client_test
|
package client_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"heckel.io/ntfy/client"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfig_Load(t *testing.T) {
|
func TestConfig_Load(t *testing.T) {
|
||||||
|
|
|
@ -2,10 +2,11 @@ package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RequestOption is a generic request option that can be added to Client calls
|
// RequestOption is a generic request option that can be added to Client calls
|
||||||
|
|
|
@ -5,6 +5,7 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"heckel.io/ntfy/auth"
|
"heckel.io/ntfy/auth"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
|
|
|
@ -2,11 +2,12 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"heckel.io/ntfy/server"
|
"heckel.io/ntfy/server"
|
||||||
"heckel.io/ntfy/test"
|
"heckel.io/ntfy/test"
|
||||||
"testing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCLI_Access_Show(t *testing.T) {
|
func TestCLI_Access_Show(t *testing.T) {
|
||||||
|
|
|
@ -2,10 +2,11 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"github.com/urfave/cli/v2/altsrc"
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
"heckel.io/ntfy/log"
|
"heckel.io/ntfy/log"
|
||||||
"os"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
@ -3,11 +3,12 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
"heckel.io/ntfy/client"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"heckel.io/ntfy/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// This only contains helpers so far
|
// This only contains helpers so far
|
||||||
|
|
|
@ -2,11 +2,12 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"github.com/urfave/cli/v2/altsrc"
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"os"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
|
// initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewYamlSourceFromFile(t *testing.T) {
|
func TestNewYamlSourceFromFile(t *testing.T) {
|
||||||
|
|
|
@ -3,16 +3,17 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
"heckel.io/ntfy/client"
|
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"heckel.io/ntfy/client"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
|
@ -2,14 +2,15 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"heckel.io/ntfy/test"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/test"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
||||||
|
|
|
@ -3,16 +3,17 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
"heckel.io/ntfy/client"
|
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"heckel.io/ntfy/client"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"heckel.io/ntfy/server"
|
"heckel.io/ntfy/server"
|
||||||
"heckel.io/ntfy/test"
|
"heckel.io/ntfy/test"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCLI_User_Add(t *testing.T) {
|
func TestCLI_User_Add(t *testing.T) {
|
||||||
|
|
|
@ -14,4 +14,3 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- 80:80
|
- 80:80
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|
|
@ -1099,4 +1099,3 @@ OPTIONS:
|
||||||
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||||
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
|
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -57,4 +57,3 @@ just the server.
|
||||||
$ ntfy serve
|
$ ntfy serve
|
||||||
2021/12/17 08:16:01 Listening on :80/http
|
2021/12/17 08:16:01 Listening on :80/http
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -570,5 +570,3 @@ Example `template.html`:
|
||||||
|
|
||||||
Add notification on Rundeck (attachment type must be: `Attached as file to email`):
|
Add notification on Rundeck (attachment type must be: `Attached as file to email`):
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -91,5 +91,3 @@ Here's another video showing the entire process:
|
||||||
<video controls muted autoplay loop width="650" src="static/img/android-video-overview.mp4"></video>
|
<video controls muted autoplay loop width="650" src="static/img/android-video-overview.mp4"></video>
|
||||||
<figcaption>Sending push notifications to your Android phone</figcaption>
|
<figcaption>Sending push notifications to your Android phone</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
|
||||||
|
|
15
docs/static/css/extra.css
vendored
15
docs/static/css/extra.css
vendored
|
@ -19,22 +19,25 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.admonition {
|
.admonition {
|
||||||
font-size: .74rem !important;
|
font-size: 0.74rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
article {
|
article {
|
||||||
padding-bottom: 50px;
|
padding-bottom: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
figure img, figure video {
|
figure img,
|
||||||
|
figure video {
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-md-color-scheme="default"] figure img, body[data-md-color-scheme="default"] figure video {
|
body[data-md-color-scheme="default"] figure img,
|
||||||
|
body[data-md-color-scheme="default"] figure video {
|
||||||
filter: drop-shadow(3px 3px 3px #ccc);
|
filter: drop-shadow(3px 3px 3px #ccc);
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-md-color-scheme="slate"] figure img, body[data-md-color-scheme="slate"] figure video {
|
body[data-md-color-scheme="slate"] figure img,
|
||||||
|
body[data-md-color-scheme="slate"] figure video {
|
||||||
filter: drop-shadow(3px 3px 3px #1a1313);
|
filter: drop-shadow(3px 3px 3px #1a1313);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,7 +53,7 @@ figure video {
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-md-box td {
|
.remove-md-box td {
|
||||||
padding: 0 10px
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */
|
/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */
|
||||||
|
@ -111,7 +114,7 @@ figure video {
|
||||||
|
|
||||||
.lightbox .close-lightbox::after,
|
.lightbox .close-lightbox::after,
|
||||||
.lightbox .close-lightbox::before {
|
.lightbox .close-lightbox::before {
|
||||||
content: '';
|
content: "";
|
||||||
width: 3px;
|
width: 3px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
background-color: #ddd;
|
background-color: #ddd;
|
||||||
|
|
62
docs/static/js/extra.js
vendored
62
docs/static/js/extra.js
vendored
|
@ -1,51 +1,59 @@
|
||||||
// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
|
// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
|
||||||
|
|
||||||
const savedCodeTab = localStorage.getItem('savedTab')
|
const savedCodeTab = localStorage.getItem("savedTab");
|
||||||
const codeTabs = document.querySelectorAll(".tabbed-set > input")
|
const codeTabs = document.querySelectorAll(".tabbed-set > input");
|
||||||
for (const tab of codeTabs) {
|
for (const tab of codeTabs) {
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
const current = document.querySelector(`label[for=${tab.id}]`);
|
||||||
const pos = current.getBoundingClientRect().top
|
const pos = current.getBoundingClientRect().top;
|
||||||
const labelContent = current.innerHTML
|
const labelContent = current.innerHTML;
|
||||||
const labels = document.querySelectorAll('.tabbed-set > label, .tabbed-alternate > .tabbed-labels > label')
|
const labels = document.querySelectorAll(
|
||||||
|
".tabbed-set > label, .tabbed-alternate > .tabbed-labels > label",
|
||||||
|
);
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
if (label.innerHTML === labelContent) {
|
if (label.innerHTML === labelContent) {
|
||||||
document.querySelector(`input[id=${label.getAttribute('for')}]`).checked = true
|
document.querySelector(
|
||||||
|
`input[id=${label.getAttribute("for")}]`,
|
||||||
|
).checked = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve scroll position
|
// Preserve scroll position
|
||||||
const delta = (current.getBoundingClientRect().top) - pos
|
const delta = current.getBoundingClientRect().top - pos;
|
||||||
window.scrollBy(0, delta)
|
window.scrollBy(0, delta);
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
localStorage.setItem('savedTab', labelContent)
|
localStorage.setItem("savedTab", labelContent);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Select saved tab
|
// Select saved tab
|
||||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
const current = document.querySelector(`label[for=${tab.id}]`);
|
||||||
const labelContent = current.innerHTML
|
const labelContent = current.innerHTML;
|
||||||
if (savedCodeTab === labelContent) {
|
if (savedCodeTab === labelContent) {
|
||||||
tab.checked = true
|
tab.checked = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightbox for screenshot
|
// Lightbox for screenshot
|
||||||
|
|
||||||
const lightbox = document.createElement('div');
|
const lightbox = document.createElement("div");
|
||||||
lightbox.classList.add('lightbox');
|
lightbox.classList.add("lightbox");
|
||||||
document.body.appendChild(lightbox);
|
document.body.appendChild(lightbox);
|
||||||
|
|
||||||
const showScreenshotOverlay = (e, el, group, index) => {
|
const showScreenshotOverlay = (e, el, group, index) => {
|
||||||
lightbox.classList.add('show');
|
lightbox.classList.add("show");
|
||||||
document.addEventListener('keydown', nextScreenshotKeyboardListener);
|
document.addEventListener("keydown", nextScreenshotKeyboardListener);
|
||||||
return showScreenshot(e, group, index);
|
return showScreenshot(e, group, index);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showScreenshot = (e, group, index) => {
|
const showScreenshot = (e, group, index) => {
|
||||||
const actualIndex = resolveScreenshotIndex(group, index);
|
const actualIndex = resolveScreenshotIndex(group, index);
|
||||||
lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[group][actualIndex].innerHTML;
|
lightbox.innerHTML =
|
||||||
lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e, group, actualIndex+1); };
|
'<div class="close-lightbox"></div>' +
|
||||||
|
screenshots[group][actualIndex].innerHTML;
|
||||||
|
lightbox.querySelector("img").onclick = (e) => {
|
||||||
|
return showScreenshot(e, group, actualIndex + 1);
|
||||||
|
};
|
||||||
currentScreenshotGroup = group;
|
currentScreenshotGroup = group;
|
||||||
currentScreenshotIndex = actualIndex;
|
currentScreenshotIndex = actualIndex;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
@ -70,8 +78,8 @@ const resolveScreenshotIndex = (group, index) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideScreenshotOverlay = (e) => {
|
const hideScreenshotOverlay = (e) => {
|
||||||
lightbox.classList.remove('show');
|
lightbox.classList.remove("show");
|
||||||
document.removeEventListener('keydown', nextScreenshotKeyboardListener);
|
document.removeEventListener("keydown", nextScreenshotKeyboardListener);
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextScreenshotKeyboardListener = (e) => {
|
const nextScreenshotKeyboardListener = (e) => {
|
||||||
|
@ -85,14 +93,16 @@ const nextScreenshotKeyboardListener = (e) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentScreenshotGroup = '';
|
let currentScreenshotGroup = "";
|
||||||
let currentScreenshotIndex = 0;
|
let currentScreenshotIndex = 0;
|
||||||
let screenshots = {};
|
let screenshots = {};
|
||||||
Array.from(document.getElementsByClassName('screenshots')).forEach((sg) => {
|
Array.from(document.getElementsByClassName("screenshots")).forEach((sg) => {
|
||||||
const group = sg.id;
|
const group = sg.id;
|
||||||
screenshots[group] = [...sg.querySelectorAll('a')];
|
screenshots[group] = [...sg.querySelectorAll("a")];
|
||||||
screenshots[group].forEach((el, index) => {
|
screenshots[group].forEach((el, index) => {
|
||||||
el.onclick = (e) => { return showScreenshotOverlay(e, el, group, index); };
|
el.onclick = (e) => {
|
||||||
|
return showScreenshotOverlay(e, el, group, index);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,20 +1,29 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<title>ntfy.sh: EventSource Example</title>
|
<title>ntfy.sh: EventSource Example</title>
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<style>
|
<style>
|
||||||
body { font-size: 1.2em; line-height: 130%; }
|
body {
|
||||||
#events { font-family: monospace; }
|
font-size: 1.2em;
|
||||||
|
line-height: 130%;
|
||||||
|
}
|
||||||
|
#events {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>ntfy.sh: EventSource Example</h1>
|
<h1>ntfy.sh: EventSource Example</h1>
|
||||||
<p>
|
<p>
|
||||||
This is an example showing how to use <a href="https://ntfy.sh">ntfy.sh</a> with
|
This is an example showing how to use
|
||||||
<a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a>.<br/>
|
<a href="https://ntfy.sh">ntfy.sh</a> with
|
||||||
This example doesn't need a server. You can just save the HTML page and run it from anywhere.
|
<a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource"
|
||||||
|
>EventSource</a
|
||||||
|
>.<br />
|
||||||
|
This example doesn't need a server. You can just save the HTML page and
|
||||||
|
run it from anywhere.
|
||||||
</p>
|
</p>
|
||||||
<button id="publishButton">Send test notification</button>
|
<button id="publishButton">Send test notification</button>
|
||||||
<p><b>Log:</b></p>
|
<p><b>Log:</b></p>
|
||||||
|
@ -23,34 +32,33 @@
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
const publishURL = `https://ntfy.sh/example`;
|
const publishURL = `https://ntfy.sh/example`;
|
||||||
const subscribeURL = `https://ntfy.sh/example/sse`;
|
const subscribeURL = `https://ntfy.sh/example/sse`;
|
||||||
const events = document.getElementById('events');
|
const events = document.getElementById("events");
|
||||||
const eventSource = new EventSource(subscribeURL);
|
const eventSource = new EventSource(subscribeURL);
|
||||||
|
|
||||||
// Publish button
|
// Publish button
|
||||||
document.getElementById("publishButton").onclick = () => {
|
document.getElementById("publishButton").onclick = () => {
|
||||||
fetch(publishURL, {
|
fetch(publishURL, {
|
||||||
method: 'POST', // works with PUT as well, though that sends an OPTIONS request too!
|
method: "POST", // works with PUT as well, though that sends an OPTIONS request too!
|
||||||
body: `It is ${new Date().toString()}. This is a test.`
|
body: `It is ${new Date().toString()}. This is a test.`,
|
||||||
})
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Incoming events
|
// Incoming events
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
let event = document.createElement('div');
|
let event = document.createElement("div");
|
||||||
event.innerHTML = `EventSource connected to ${subscribeURL}`;
|
event.innerHTML = `EventSource connected to ${subscribeURL}`;
|
||||||
events.appendChild(event);
|
events.appendChild(event);
|
||||||
};
|
};
|
||||||
eventSource.onerror = (e) => {
|
eventSource.onerror = (e) => {
|
||||||
let event = document.createElement('div');
|
let event = document.createElement("div");
|
||||||
event.innerHTML = `EventSource error: Failed to connect to ${subscribeURL}`;
|
event.innerHTML = `EventSource error: Failed to connect to ${subscribeURL}`;
|
||||||
events.appendChild(event);
|
events.appendChild(event);
|
||||||
};
|
};
|
||||||
eventSource.onmessage = (e) => {
|
eventSource.onmessage = (e) => {
|
||||||
let event = document.createElement('div');
|
let event = document.createElement("div");
|
||||||
event.innerHTML = e.data;
|
event.innerHTML = e.data;
|
||||||
events.appendChild(event);
|
events.appendChild(event);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
5
main.go
5
main.go
|
@ -2,10 +2,11 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
"heckel.io/ntfy/cmd"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"heckel.io/ntfy/cmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -92,5 +92,3 @@ nav:
|
||||||
- "Deprecation notices": deprecations.md
|
- "Deprecation notices": deprecations.md
|
||||||
- "Development": develop.md
|
- "Development": develop.md
|
||||||
- "Privacy policy": privacy.md
|
- "Privacy policy": privacy.md
|
||||||
|
|
||||||
|
|
||||||
|
|
39341
scripts/emoji.json
39341
scripts/emoji.json
File diff suppressed because it is too large
Load diff
|
@ -7,4 +7,3 @@ if [ "$1" = "purge" ] || [ "$1" = "0" ]; then
|
||||||
rm -f /etc/ntfy/server.yml /etc/ntfy/client.yml
|
rm -f /etc/ntfy/server.yml /etc/ntfy/client.yml
|
||||||
rmdir /etc/ntfy || true
|
rmdir /etc/ntfy || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
@ -4,10 +4,11 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseActions(t *testing.T) {
|
func TestParseActions(t *testing.T) {
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
package server_test
|
package server_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"heckel.io/ntfy/server"
|
"heckel.io/ntfy/server"
|
||||||
"testing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfig_New(t *testing.T) {
|
func TestConfig_New(t *testing.T) {
|
||||||
|
|
|
@ -3,13 +3,14 @@ package server
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -3,12 +3,13 @@ package server
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -4,14 +4,15 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
firebase "firebase.google.com/go/v4"
|
firebase "firebase.google.com/go/v4"
|
||||||
"firebase.google.com/go/v4/messaging"
|
"firebase.google.com/go/v4/messaging"
|
||||||
"fmt"
|
|
||||||
"google.golang.org/api/option"
|
"google.golang.org/api/option"
|
||||||
"heckel.io/ntfy/auth"
|
"heckel.io/ntfy/auth"
|
||||||
"heckel.io/ntfy/log"
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
@ -4,11 +4,12 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Matrix Push Gateway / UnifiedPush / ntfy integration:
|
// Matrix Push Gateway / UnifiedPush / ntfy integration:
|
||||||
|
|
|
@ -172,7 +172,7 @@ func TestServer_StaticSites(t *testing.T) {
|
||||||
|
|
||||||
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
|
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
require.Contains(t, rr.Body.String(), `html, body {`)
|
require.Contains(t, rr.Body.String(), "html,\nbody {")
|
||||||
|
|
||||||
rr = request(t, s, "GET", "/docs", "", nil)
|
rr = request(t, s, "GET", "/docs", "", nil)
|
||||||
require.Equal(t, 301, rr.Code)
|
require.Equal(t, 301, rr.Code)
|
||||||
|
|
|
@ -4,14 +4,15 @@ import (
|
||||||
_ "embed" // required by go:embed
|
_ "embed" // required by go:embed
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"mime"
|
"mime"
|
||||||
"net"
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mailer interface {
|
type mailer interface {
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFormatMail_Basic(t *testing.T) {
|
func TestFormatMail_Basic(t *testing.T) {
|
||||||
|
|
|
@ -4,8 +4,6 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/emersion/go-smtp"
|
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
|
@ -15,6 +13,9 @@ import (
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/emersion/go-smtp"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSmtpBackend_Multipart(t *testing.T) {
|
func TestSmtpBackend_Multipart(t *testing.T) {
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// topic represents a channel to which subscribers can subscribe, and publishers
|
// topic represents a channel to which subscribers can subscribe, and publishers
|
||||||
|
|
|
@ -2,11 +2,12 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/emersion/go-smtp"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||||
|
|
|
@ -3,11 +3,12 @@ package server
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestReadBoolParam(t *testing.T) {
|
func TestReadBoolParam(t *testing.T) {
|
||||||
|
|
|
@ -2,12 +2,13 @@ package test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/server"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
|
@ -2,13 +2,14 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
firebase "firebase.google.com/go/v4"
|
|
||||||
"firebase.google.com/go/v4/messaging"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"google.golang.org/api/option"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
firebase "firebase.google.com/go/v4"
|
||||||
|
"firebase.google.com/go/v4/messaging"
|
||||||
|
"google.golang.org/api/option"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
package util_test
|
package util_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBatchingQueue_InfTimeout(t *testing.T) {
|
func TestBatchingQueue_InfTimeout(t *testing.T) {
|
||||||
|
|
|
@ -2,9 +2,10 @@ package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSniffWriter_WriteHTML(t *testing.T) {
|
func TestSniffWriter_WriteHTML(t *testing.T) {
|
||||||
|
|
|
@ -2,11 +2,12 @@ package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -2,11 +2,12 @@ package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGzipHandler(t *testing.T) {
|
func TestGzipHandler(t *testing.T) {
|
||||||
|
|
|
@ -2,10 +2,11 @@ package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"golang.org/x/time/rate"
|
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrLimitReached is the error returned by the Limiter and LimitWriter when the predefined limit has been reached
|
// ErrLimitReached is the error returned by the Limiter and LimitWriter when the predefined limit has been reached
|
||||||
|
|
|
@ -2,9 +2,10 @@ package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFixedLimiter_Add(t *testing.T) {
|
func TestFixedLimiter_Add(t *testing.T) {
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPeak_LimitReached(t *testing.T) {
|
func TestPeak_LimitReached(t *testing.T) {
|
||||||
|
|
|
@ -2,11 +2,12 @@ package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/olebedev/when"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/olebedev/when"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -5,5 +5,5 @@
|
||||||
|
|
||||||
var config = {
|
var config = {
|
||||||
appRoot: "/",
|
appRoot: "/",
|
||||||
disallowedTopics: ["docs", "static", "file", "app", "settings"]
|
disallowedTopics: ["docs", "static", "file", "app", "settings"],
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,38 +1,46 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
|
|
||||||
<title>ntfy.sh | Send push notifications to your phone via PUT/POST</title>
|
<title>ntfy.sh | Send push notifications to your phone via PUT/POST</title>
|
||||||
<link rel="stylesheet" href="static/css/home.css" type="text/css">
|
<link rel="stylesheet" href="static/css/home.css" type="text/css" />
|
||||||
|
|
||||||
<!-- Mobile view -->
|
<!-- Mobile view -->
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
<meta
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
name="viewport"
|
||||||
<meta name="HandheldFriendly" content="true">
|
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 -->
|
<!-- Mobile browsers, background color -->
|
||||||
<meta name="theme-color" content="#317f6f">
|
<meta name="theme-color" content="#317f6f" />
|
||||||
<meta name="msapplication-navbutton-color" content="#317f6f">
|
<meta name="msapplication-navbutton-color" content="#317f6f" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f">
|
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
|
||||||
|
|
||||||
<!-- Favicon, see favicon.io -->
|
<!-- Favicon, see favicon.io -->
|
||||||
<link rel="icon" type="image/png" href="static/img/favicon.png">
|
<link rel="icon" type="image/png" href="static/img/favicon.png" />
|
||||||
|
|
||||||
<!-- Previews in Google, Slack, WhatsApp, etc. -->
|
<!-- Previews in Google, Slack, WhatsApp, etc. -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:locale" content="en_US" />
|
<meta property="og:locale" content="en_US" />
|
||||||
<meta property="og:site_name" content="ntfy.sh" />
|
<meta property="og:site_name" content="ntfy.sh" />
|
||||||
<meta property="og:title" content="ntfy.sh | Push notifications to your phone or desktop via PUT/POST" />
|
<meta
|
||||||
<meta property="og:description" content="ntfy is a simple HTTP-based pub-sub notification service. It allows you to send desktop notifications via scripts from any computer, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." />
|
property="og:title"
|
||||||
|
content="ntfy.sh | Push notifications to your phone or desktop via PUT/POST"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="ntfy is a simple HTTP-based pub-sub notification service. It allows you to send desktop notifications via scripts from any computer, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy."
|
||||||
|
/>
|
||||||
<meta property="og:image" content="/static/img/ntfy.png" />
|
<meta property="og:image" content="/static/img/ntfy.png" />
|
||||||
<meta property="og:url" content="https://ntfy.sh" />
|
<meta property="og:url" content="https://ntfy.sh" />
|
||||||
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link rel="stylesheet" href="static/css/fonts.css" type="text/css">
|
<link rel="stylesheet" href="static/css/fonts.css" type="text/css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav id="header">
|
<nav id="header">
|
||||||
<div id="headerBox">
|
<div id="headerBox">
|
||||||
<img id="logo" src="static/img/ntfy.png" alt="logo" />
|
<img id="logo" src="static/img/ntfy.png" alt="logo" />
|
||||||
|
@ -49,97 +57,156 @@
|
||||||
<div id="main">
|
<div id="main">
|
||||||
<h1>Send push notifications to your phone or desktop via PUT/POST</h1>
|
<h1>Send push notifications to your phone or desktop via PUT/POST</h1>
|
||||||
<p>
|
<p>
|
||||||
<b>ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based <a href="https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern">pub-sub</a> notification service.
|
<b>ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based
|
||||||
It allows you to send notifications to your phone or desktop via scripts from any computer,
|
<a
|
||||||
entirely <b>without signup, cost or setup</b>. It's also <a href="https://github.com/binwiederhier/ntfy">open source</a> if you want to run your own.
|
href="https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern"
|
||||||
|
>pub-sub</a
|
||||||
|
>
|
||||||
|
notification service. It allows you to send notifications to your phone
|
||||||
|
or desktop via scripts from any computer, entirely
|
||||||
|
<b>without signup, cost or setup</b>. It's also
|
||||||
|
<a href="https://github.com/binwiederhier/ntfy">open source</a> if you
|
||||||
|
want to run your own.
|
||||||
</p>
|
</p>
|
||||||
<div id="screenshots">
|
<div id="screenshots">
|
||||||
<a href="static/img/screenshot-curl.png"><img src="static/img/screenshot-curl.png"/></a>
|
<a href="static/img/screenshot-curl.png"
|
||||||
<a href="static/img/screenshot-web-detail.png"><img src="static/img/screenshot-web-detail.png"/></a>
|
><img src="static/img/screenshot-curl.png"
|
||||||
|
/></a>
|
||||||
|
<a href="static/img/screenshot-web-detail.png"
|
||||||
|
><img src="static/img/screenshot-web-detail.png"
|
||||||
|
/></a>
|
||||||
<span class="nowrap">
|
<span class="nowrap">
|
||||||
<a href="static/img/screenshot-phone-main.jpg"><img src="static/img/screenshot-phone-main.jpg"/></a>
|
<a href="static/img/screenshot-phone-main.jpg"
|
||||||
<a href="static/img/screenshot-phone-detail.jpg"><img src="static/img/screenshot-phone-detail.jpg"/></a>
|
><img src="static/img/screenshot-phone-main.jpg"
|
||||||
<a href="static/img/screenshot-phone-notification.jpg"><img src="static/img/screenshot-phone-notification.jpg"/></a>
|
/></a>
|
||||||
|
<a href="static/img/screenshot-phone-detail.jpg"
|
||||||
|
><img src="static/img/screenshot-phone-detail.jpg"
|
||||||
|
/></a>
|
||||||
|
<a href="static/img/screenshot-phone-notification.jpg"
|
||||||
|
><img src="static/img/screenshot-phone-notification.jpg"
|
||||||
|
/></a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 id="publish" class="anchor">Publishing messages</h2>
|
<h2 id="publish" class="anchor">Publishing messages</h2>
|
||||||
<p>
|
<p>
|
||||||
<a href="docs/publish/">Publishing messages</a> can be done via PUT or POST. Topics are created on the fly by subscribing or publishing to them.
|
<a href="docs/publish/">Publishing messages</a> can be done via PUT or
|
||||||
Because there is no sign-up, <b>the topic is essentially a password</b>, so pick something that's not easily guessable.
|
POST. Topics are created on the fly by subscribing or publishing to
|
||||||
|
them. Because there is no sign-up,
|
||||||
|
<b>the topic is essentially a password</b>, so pick something that's not
|
||||||
|
easily guessable.
|
||||||
</p>
|
</p>
|
||||||
<p class="smallMarginBottom">
|
<p class="smallMarginBottom">
|
||||||
Here's an example showing how to publish a message using a POST request (via <tt>curl -d</tt>):
|
Here's an example showing how to publish a message using a POST request
|
||||||
|
(via <tt>curl -d</tt>):
|
||||||
</p>
|
</p>
|
||||||
<code>
|
<code>
|
||||||
curl -d "Backup successful 😀" <span class="ntfyUrl">ntfy.sh</span>/mytopic
|
curl -d "Backup successful 😀"
|
||||||
|
<span class="ntfyUrl">ntfy.sh</span>/mytopic
|
||||||
</code>
|
</code>
|
||||||
<p class="smallMarginBottom">
|
<p class="smallMarginBottom">
|
||||||
There are <a href="docs/publish/">more features</a> related to publishing messages: You can set a
|
There are <a href="docs/publish/">more features</a> related to
|
||||||
<a href="docs/publish/#message-priority">notification priority</a>, a <a href="docs/publish/#message-title">title</a>,
|
publishing messages: You can set a
|
||||||
and <a href="docs/publish/#tags-emojis">tag messages</a>.
|
<a href="docs/publish/#message-priority">notification priority</a>, a
|
||||||
Here's an example using some of them together:
|
<a href="docs/publish/#message-title">title</a>, and
|
||||||
|
<a href="docs/publish/#tags-emojis">tag messages</a>. Here's an example
|
||||||
|
using some of them together:
|
||||||
</p>
|
</p>
|
||||||
<code>
|
<code>
|
||||||
curl \<br />
|
curl \<br />
|
||||||
-H "Title: Unauthorized access detected" \<br />
|
-H "Title: Unauthorized access detected" \<br />
|
||||||
-H "Priority: urgent" \<br />
|
-H "Priority: urgent" \<br />
|
||||||
-H "Tags: warning,skull" \<br />
|
-H "Tags: warning,skull" \<br />
|
||||||
-d "Remote access to $(hostname) detected. Act right away." \<br/>
|
-d "Remote access to $(hostname) detected. Act right away."
|
||||||
|
\<br />
|
||||||
<span class="ntfyUrl">ntfy.sh</span>/mytopic
|
<span class="ntfyUrl">ntfy.sh</span>/mytopic
|
||||||
</code>
|
</code>
|
||||||
<p>
|
<p>
|
||||||
Here's what that looks like in the <a href="docs/subscribe/phone/">Android app</a>:
|
Here's what that looks like in the
|
||||||
|
<a href="docs/subscribe/phone/">Android app</a>:
|
||||||
</p>
|
</p>
|
||||||
<figure>
|
<figure>
|
||||||
<img src="static/img/screenshot-phone-popover.png" style="max-height: 200px"/>
|
<img
|
||||||
|
src="static/img/screenshot-phone-popover.png"
|
||||||
|
style="max-height: 200px"
|
||||||
|
/>
|
||||||
<figcaption>Urgent notification with pop-over</figcaption>
|
<figcaption>Urgent notification with pop-over</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<h2 id="subscribe" class="anchor">Subscribe to a topic</h2>
|
<h2 id="subscribe" class="anchor">Subscribe to a topic</h2>
|
||||||
<p>
|
<p>
|
||||||
You can create and subscribe to a topic either <a href="docs/subscribe/phone/">using your phone</a>,
|
You can create and subscribe to a topic either
|
||||||
in <a href="docs/subscribe/web/">this web UI</a>, or in your own app by <a href="docs/subscribe/api/">subscribing via the API</a>.
|
<a href="docs/subscribe/phone/">using your phone</a>, in
|
||||||
|
<a href="docs/subscribe/web/">this web UI</a>, or in your own app by
|
||||||
|
<a href="docs/subscribe/api/">subscribing via the API</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 id="subscribe-phone" class="anchor">Subscribe from your phone</h3>
|
<h3 id="subscribe-phone" class="anchor">Subscribe from your phone</h3>
|
||||||
<p>
|
<p>
|
||||||
Simply get the app and start <a href="docs/publish/">publishing messages</a>. To learn more about the app,
|
Simply get the app and start
|
||||||
<a href="docs/subscribe/phone/">check out the documentation</a>.
|
<a href="docs/publish/">publishing messages</a>. To learn more about the
|
||||||
|
app, <a href="docs/subscribe/phone/">check out the documentation</a>.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"></a>
|
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"
|
||||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"></a>
|
><img src="static/img/badge-googleplay.png"
|
||||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="static/img/badge-appstore.png"></a>
|
/></a>
|
||||||
</p>
|
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"
|
||||||
<p>
|
><img src="static/img/badge-fdroid.png"
|
||||||
Here's a video showing the app in action:
|
/></a>
|
||||||
|
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"
|
||||||
|
><img src="static/img/badge-appstore.png"
|
||||||
|
/></a>
|
||||||
</p>
|
</p>
|
||||||
|
<p>Here's a video showing the app in action:</p>
|
||||||
<figure>
|
<figure>
|
||||||
<video controls muted autoplay loop src="static/img/android-video-overview.mp4" style="max-width: 650px"></video>
|
<video
|
||||||
<figcaption>Sending push notifications to your Android phone</figcaption>
|
controls
|
||||||
|
muted
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
src="static/img/android-video-overview.mp4"
|
||||||
|
style="max-width: 650px"
|
||||||
|
></video>
|
||||||
|
<figcaption>
|
||||||
|
Sending push notifications to your Android phone
|
||||||
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<h3 id="subscribe-web" class="anchor">Subscribe via web app</h3>
|
<h3 id="subscribe-web" class="anchor">Subscribe via web app</h3>
|
||||||
<p>
|
<p>
|
||||||
Subscribe to topics in the <a href="app">web app</a> and receive messages as <b>desktop notification</b>.
|
Subscribe to topics in the <a href="app">web app</a> and receive
|
||||||
It is available at <b><a href="app"><span class="ntfyUrl">ntfy.sh</span>/app</a></b>.
|
messages as <b>desktop notification</b>. It is available at
|
||||||
|
<b
|
||||||
|
><a href="app"><span class="ntfyUrl">ntfy.sh</span>/app</a></b
|
||||||
|
>.
|
||||||
</p>
|
</p>
|
||||||
<figure>
|
<figure>
|
||||||
<a href="app"><img src="static/img/screenshot-web-detail.png" width="100%"/></a>
|
<a href="app"
|
||||||
<figcaption>ntfy web app, available at <a href="app"><span class="ntfyUrl">ntfy.sh</span>/app</a></figcaption>
|
><img src="static/img/screenshot-web-detail.png" width="100%"
|
||||||
|
/></a>
|
||||||
|
<figcaption>
|
||||||
|
ntfy web app, available at
|
||||||
|
<a href="app"><span class="ntfyUrl">ntfy.sh</span>/app</a>
|
||||||
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<h3 id="subscribe-api" class="anchor">Subscribe using the API</h3>
|
<h3 id="subscribe-api" class="anchor">Subscribe using the API</h3>
|
||||||
<p>
|
<p>
|
||||||
There's a super simple API that you can use to integrate your own app. You can consume
|
There's a super simple API that you can use to integrate your own app.
|
||||||
a <a href="docs/subscribe/api/#subscribe-as-json-stream">JSON stream</a>,
|
You can consume a
|
||||||
an <a href="docs/subscribe/api/#subscribe-as-sse-stream">SSE/EventSource stream</a>,
|
<a href="docs/subscribe/api/#subscribe-as-json-stream">JSON stream</a>,
|
||||||
a <a href="docs/subscribe/api/#subscribe-as-raw-stream">plain text stream</a>,
|
an
|
||||||
or <a href="docs/subscribe/api/#websockets">via WebSockets</a>.
|
<a href="docs/subscribe/api/#subscribe-as-sse-stream"
|
||||||
|
>SSE/EventSource stream</a
|
||||||
|
>, a
|
||||||
|
<a href="docs/subscribe/api/#subscribe-as-raw-stream"
|
||||||
|
>plain text stream</a
|
||||||
|
>, or <a href="docs/subscribe/api/#websockets">via WebSockets</a>.
|
||||||
</p>
|
</p>
|
||||||
<p class="smallMarginBottom">
|
<p class="smallMarginBottom">
|
||||||
Here's an example for JSON. The <b>connection stays open</b>, so you can retrieve messages as they come in:
|
Here's an example for JSON. The <b>connection stays open</b>, so you can
|
||||||
|
retrieve messages as they come in:
|
||||||
</p>
|
</p>
|
||||||
<code>
|
<code>
|
||||||
$ curl -s <span class="ntfyUrl">ntfy.sh</span>/mytopic/json<br />
|
$ curl -s <span class="ntfyUrl">ntfy.sh</span>/mytopic/json<br />
|
||||||
|
@ -148,33 +215,50 @@
|
||||||
{"id":"DGUDShMCsc","time":1635528787,"event":"keepalive","topic":"mytopic"}<br />
|
{"id":"DGUDShMCsc","time":1635528787,"event":"keepalive","topic":"mytopic"}<br />
|
||||||
...
|
...
|
||||||
</code>
|
</code>
|
||||||
<p>
|
<p>Here's a short video demonstrating it in action:</p>
|
||||||
Here's a short video demonstrating it in action:
|
|
||||||
</p>
|
|
||||||
<figure>
|
<figure>
|
||||||
<video controls muted autoplay loop src="static/img/android-video-subscribe-api.mp4" style="max-width: 650px"></video>
|
<video
|
||||||
<figcaption>Subscribing to the JSON stream with <tt>curl</tt></figcaption>
|
controls
|
||||||
|
muted
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
src="static/img/android-video-subscribe-api.mp4"
|
||||||
|
style="max-width: 650px"
|
||||||
|
></video>
|
||||||
|
<figcaption>
|
||||||
|
Subscribing to the JSON stream with <tt>curl</tt>
|
||||||
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<h3 id="docs" class="anchor">Check out the docs!</h3>
|
<h3 id="docs" class="anchor">Check out the docs!</h3>
|
||||||
<p>
|
<p>
|
||||||
ntfy has so many more features and you can learn about all of them <a href="docs/">in the documentation</a>
|
ntfy has so many more features and you can learn about all of them
|
||||||
(I tried my very best to make it the best docs ever 😉, not sure if I succeeded, hehe).
|
<a href="docs/">in the documentation</a>
|
||||||
|
(I tried my very best to make it the best docs ever 😉, not sure if I
|
||||||
|
succeeded, hehe).
|
||||||
</p>
|
</p>
|
||||||
<figure>
|
<figure>
|
||||||
<a href="docs/"><img width="100%" src="static/img/screenshot-docs.png"/></a>
|
<a href="docs/"
|
||||||
|
><img width="100%" src="static/img/screenshot-docs.png"
|
||||||
|
/></a>
|
||||||
<figcaption>Check out the documentation</figcaption>
|
<figcaption>Check out the documentation</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<h3 id="free-software" class="anchor">100% open source & forever free</h3>
|
<h3 id="free-software" class="anchor">
|
||||||
|
100% open source & forever free
|
||||||
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
I love free software, and I'm doing this because it's fun. I have no bad intentions, and I will
|
I love free software, and I'm doing this because it's fun. I have no bad
|
||||||
never monetize or sell your information. This service will always stay
|
intentions, and I will never monetize or sell your information. This
|
||||||
<a href="https://github.com/binwiederhier/ntfy">free and open</a>.
|
service will always stay
|
||||||
You can read more in the <a href="docs/faq/">FAQs</a> and in the <a href="docs/privacy/">privacy policy</a>.
|
<a href="https://github.com/binwiederhier/ntfy">free and open</a>. You
|
||||||
|
can read more in the <a href="docs/faq/">FAQs</a> and in the
|
||||||
|
<a href="docs/privacy/">privacy policy</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<center id="ironicCenterTagDontFreakOut"><i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i></center>
|
<center id="ironicCenterTagDontFreakOut">
|
||||||
|
<i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i>
|
||||||
|
</center>
|
||||||
</div>
|
</div>
|
||||||
<div id="lightbox" class="lightbox"></div>
|
<div id="lightbox" class="lightbox"></div>
|
||||||
<script src="static/js/home.js"></script>
|
<script src="static/js/home.js"></script>
|
||||||
|
|
|
@ -1,28 +1,38 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<title>ntfy web</title>
|
<title>ntfy web</title>
|
||||||
|
|
||||||
<!-- Mobile view -->
|
<!-- Mobile view -->
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
<meta
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
name="viewport"
|
||||||
<meta name="HandheldFriendly" content="true">
|
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 -->
|
<!-- Mobile browsers, background color -->
|
||||||
<meta name="theme-color" content="#317f6f">
|
<meta name="theme-color" content="#317f6f" />
|
||||||
<meta name="msapplication-navbutton-color" content="#317f6f">
|
<meta name="msapplication-navbutton-color" content="#317f6f" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f">
|
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
|
||||||
|
|
||||||
<!-- Favicon, see favicon.io -->
|
<!-- Favicon, see favicon.io -->
|
||||||
<link rel="icon" type="image/png" href="%PUBLIC_URL%/static/img/favicon.png">
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
href="%PUBLIC_URL%/static/img/favicon.png"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Previews in Google, Slack, WhatsApp, etc. -->
|
<!-- Previews in Google, Slack, WhatsApp, etc. -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:locale" content="en_US" />
|
<meta property="og:locale" content="en_US" />
|
||||||
<meta property="og:site_name" content="ntfy web" />
|
<meta property="og:site_name" content="ntfy web" />
|
||||||
<meta property="og:title" 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, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." />
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="ntfy lets you send push notifications via scripts from any computer or phone, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy."
|
||||||
|
/>
|
||||||
<meta property="og:image" content="%PUBLIC_URL%/static/img/ntfy.png" />
|
<meta property="og:image" content="%PUBLIC_URL%/static/img/ntfy.png" />
|
||||||
<meta property="og:url" content="https://ntfy.sh" />
|
<meta property="og:url" content="https://ntfy.sh" />
|
||||||
|
|
||||||
|
@ -30,12 +40,18 @@
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css">
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="%PUBLIC_URL%/static/css/fonts.css"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<noscript>
|
||||||
ntfy web requires JavaScript, but you can also use the <a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a>
|
ntfy web requires JavaScript, but you can also use the
|
||||||
or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to subscribe.
|
<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>
|
</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script src="%PUBLIC_URL%/config.js"></script>
|
<script src="%PUBLIC_URL%/config.js"></script>
|
||||||
|
|
|
@ -2,40 +2,40 @@
|
||||||
|
|
||||||
/* roboto-300 - latin */
|
/* roboto-300 - latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
src: local(''),
|
src: local(""), url("../fonts/roboto-v29-latin-300.woff2") format("woff2"),
|
||||||
url('../fonts/roboto-v29-latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||||
url('../fonts/roboto-v29-latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
url("../fonts/roboto-v29-latin-300.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* roboto-regular - latin */
|
/* roboto-regular - latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: local(''),
|
src: local(""), url("../fonts/roboto-v29-latin-regular.woff2") format("woff2"),
|
||||||
url('../fonts/roboto-v29-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||||
url('../fonts/roboto-v29-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
url("../fonts/roboto-v29-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* roboto-500 - latin */
|
/* roboto-500 - latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
src: local(''),
|
src: local(""), url("../fonts/roboto-v29-latin-500.woff2") format("woff2"),
|
||||||
url('../fonts/roboto-v29-latin-500.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||||
url('../fonts/roboto-v29-latin-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
url("../fonts/roboto-v29-latin-500.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* roboto-700 - latin */
|
/* roboto-700 - latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: "Roboto";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
src: local(''),
|
src: local(""), url("../fonts/roboto-v29-latin-700.woff2") format("woff2"),
|
||||||
url('../fonts/roboto-v29-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||||
url('../fonts/roboto-v29-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
url("../fonts/roboto-v29-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
/* general styling */
|
/* general styling */
|
||||||
|
|
||||||
html, body {
|
html,
|
||||||
font-family: 'Roboto', sans-serif;
|
body {
|
||||||
|
font-family: "Roboto", sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
color: #444;
|
color: #444;
|
||||||
|
@ -15,7 +16,8 @@ html {
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
a, a:visited {
|
a,
|
||||||
|
a:visited {
|
||||||
color: #338574;
|
color: #338574;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,7 +126,8 @@ figure {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
figure img, figure video {
|
figure img,
|
||||||
|
figure video {
|
||||||
filter: drop-shadow(3px 3px 3px #ccc);
|
filter: drop-shadow(3px 3px 3px #ccc);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
@ -200,7 +203,7 @@ figcaption {
|
||||||
|
|
||||||
.lightbox .close-lightbox::after,
|
.lightbox .close-lightbox::after,
|
||||||
.lightbox .close-lightbox::before {
|
.lightbox .close-lightbox::before {
|
||||||
content: '';
|
content: "";
|
||||||
width: 3px;
|
width: 3px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
background-color: #ddd;
|
background-color: #ddd;
|
||||||
|
@ -256,7 +259,8 @@ figcaption {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header ol li a, nav ol li a:visited {
|
#header ol li a,
|
||||||
|
nav ol li a:visited {
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
@ -271,7 +275,6 @@ li {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Hide top menu SMALL SCREEN */
|
/* Hide top menu SMALL SCREEN */
|
||||||
@media only screen and (max-width: 780px) {
|
@media only screen and (max-width: 780px) {
|
||||||
#header ol {
|
#header ol {
|
||||||
|
|
|
@ -1,24 +1,26 @@
|
||||||
|
|
||||||
/* All the things */
|
/* All the things */
|
||||||
|
|
||||||
let currentUrl = window.location.hostname;
|
let currentUrl = window.location.hostname;
|
||||||
if (window.location.port) {
|
if (window.location.port) {
|
||||||
currentUrl += ':' + window.location.port
|
currentUrl += ":" + window.location.port;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Screenshots */
|
/* Screenshots */
|
||||||
const lightbox = document.getElementById("lightbox");
|
const lightbox = document.getElementById("lightbox");
|
||||||
|
|
||||||
const showScreenshotOverlay = (e, el, index) => {
|
const showScreenshotOverlay = (e, el, index) => {
|
||||||
lightbox.classList.add('show');
|
lightbox.classList.add("show");
|
||||||
document.addEventListener('keydown', nextScreenshotKeyboardListener);
|
document.addEventListener("keydown", nextScreenshotKeyboardListener);
|
||||||
return showScreenshot(e, index);
|
return showScreenshot(e, index);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showScreenshot = (e, index) => {
|
const showScreenshot = (e, index) => {
|
||||||
const actualIndex = resolveScreenshotIndex(index);
|
const actualIndex = resolveScreenshotIndex(index);
|
||||||
lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[actualIndex].innerHTML;
|
lightbox.innerHTML =
|
||||||
lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e,actualIndex+1); };
|
'<div class="close-lightbox"></div>' + screenshots[actualIndex].innerHTML;
|
||||||
|
lightbox.querySelector("img").onclick = (e) => {
|
||||||
|
return showScreenshot(e, actualIndex + 1);
|
||||||
|
};
|
||||||
currentScreenshotIndex = actualIndex;
|
currentScreenshotIndex = actualIndex;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return false;
|
return false;
|
||||||
|
@ -42,8 +44,8 @@ const resolveScreenshotIndex = (index) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideScreenshotOverlay = (e) => {
|
const hideScreenshotOverlay = (e) => {
|
||||||
lightbox.classList.remove('show');
|
lightbox.classList.remove("show");
|
||||||
document.removeEventListener('keydown', nextScreenshotKeyboardListener);
|
document.removeEventListener("keydown", nextScreenshotKeyboardListener);
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextScreenshotKeyboardListener = (e) => {
|
const nextScreenshotKeyboardListener = (e) => {
|
||||||
|
@ -60,25 +62,27 @@ const nextScreenshotKeyboardListener = (e) => {
|
||||||
let currentScreenshotIndex = 0;
|
let currentScreenshotIndex = 0;
|
||||||
const screenshots = [...document.querySelectorAll("#screenshots a")];
|
const screenshots = [...document.querySelectorAll("#screenshots a")];
|
||||||
screenshots.forEach((el, index) => {
|
screenshots.forEach((el, index) => {
|
||||||
el.onclick = (e) => { return showScreenshotOverlay(e, el, index); };
|
el.onclick = (e) => {
|
||||||
|
return showScreenshotOverlay(e, el, index);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
lightbox.onclick = hideScreenshotOverlay;
|
lightbox.onclick = hideScreenshotOverlay;
|
||||||
|
|
||||||
// Add anchor links
|
// Add anchor links
|
||||||
document.querySelectorAll('.anchor').forEach((el) => {
|
document.querySelectorAll(".anchor").forEach((el) => {
|
||||||
if (el.hasAttribute('id')) {
|
if (el.hasAttribute("id")) {
|
||||||
const id = el.getAttribute('id');
|
const id = el.getAttribute("id");
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement("a");
|
||||||
anchor.innerHTML = `<a href="#${id}" class="anchorLink">#</a>`;
|
anchor.innerHTML = `<a href="#${id}" class="anchorLink">#</a>`;
|
||||||
el.appendChild(anchor);
|
el.appendChild(anchor);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Change ntfy.sh url and protocol to match self-hosted one
|
// Change ntfy.sh url and protocol to match self-hosted one
|
||||||
document.querySelectorAll('.ntfyUrl').forEach((el) => {
|
document.querySelectorAll(".ntfyUrl").forEach((el) => {
|
||||||
el.innerHTML = currentUrl;
|
el.innerHTML = currentUrl;
|
||||||
});
|
});
|
||||||
document.querySelectorAll('.ntfyProtocol').forEach((el) => {
|
document.querySelectorAll(".ntfyProtocol").forEach((el) => {
|
||||||
el.innerHTML = window.location.protocol + "//";
|
el.innerHTML = window.location.protocol + "//";
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,7 +6,7 @@ import {
|
||||||
topicUrlAuth,
|
topicUrlAuth,
|
||||||
topicUrlJsonPoll,
|
topicUrlJsonPoll,
|
||||||
topicUrlJsonPollWithSince,
|
topicUrlJsonPollWithSince,
|
||||||
userStatsUrl
|
userStatsUrl,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import userManager from "./UserManager";
|
import userManager from "./UserManager";
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ class Api {
|
||||||
async poll(baseUrl, topic, since) {
|
async poll(baseUrl, topic, since) {
|
||||||
const user = await userManager.get(baseUrl);
|
const user = await userManager.get(baseUrl);
|
||||||
const shortUrl = topicShortUrl(baseUrl, topic);
|
const shortUrl = topicShortUrl(baseUrl, topic);
|
||||||
const url = (since)
|
const url = since
|
||||||
? topicUrlJsonPollWithSince(baseUrl, topic, since)
|
? topicUrlJsonPollWithSince(baseUrl, topic, since)
|
||||||
: topicUrlJsonPoll(baseUrl, topic);
|
: topicUrlJsonPoll(baseUrl, topic);
|
||||||
const messages = [];
|
const messages = [];
|
||||||
|
@ -34,12 +34,12 @@ class Api {
|
||||||
const body = {
|
const body = {
|
||||||
topic: topic,
|
topic: topic,
|
||||||
message: message,
|
message: message,
|
||||||
...options
|
...options,
|
||||||
};
|
};
|
||||||
const response = await fetch(baseUrl, {
|
const response = await fetch(baseUrl, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: maybeWithBasicAuth(headers, user)
|
headers: maybeWithBasicAuth(headers, user),
|
||||||
});
|
});
|
||||||
if (response.status < 200 || response.status > 299) {
|
if (response.status < 200 || response.status > 299) {
|
||||||
throw new Error(`Unexpected response: ${response.status}`);
|
throw new Error(`Unexpected response: ${response.status}`);
|
||||||
|
@ -72,13 +72,19 @@ class Api {
|
||||||
xhr.setRequestHeader(key, value);
|
xhr.setRequestHeader(key, value);
|
||||||
}
|
}
|
||||||
xhr.upload.addEventListener("progress", onProgress);
|
xhr.upload.addEventListener("progress", onProgress);
|
||||||
xhr.addEventListener('readystatechange', (ev) => {
|
xhr.addEventListener("readystatechange", (ev) => {
|
||||||
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
|
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
|
||||||
console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response);
|
console.log(
|
||||||
|
`[Api] Publish successful (HTTP ${xhr.status})`,
|
||||||
|
xhr.response,
|
||||||
|
);
|
||||||
resolve(xhr.response);
|
resolve(xhr.response);
|
||||||
} else if (xhr.readyState === 4) {
|
} else if (xhr.readyState === 4) {
|
||||||
// Firefox bug; see description above!
|
// Firefox bug; see description above!
|
||||||
console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText);
|
console.log(
|
||||||
|
`[Api] Publish failed (HTTP ${xhr.status})`,
|
||||||
|
xhr.responseText,
|
||||||
|
);
|
||||||
let errorText;
|
let errorText;
|
||||||
try {
|
try {
|
||||||
const error = JSON.parse(xhr.responseText);
|
const error = JSON.parse(xhr.responseText);
|
||||||
|
@ -91,13 +97,13 @@ class Api {
|
||||||
xhr.abort();
|
xhr.abort();
|
||||||
reject(errorText ?? "An error occurred");
|
reject(errorText ?? "An error occurred");
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
xhr.send(body);
|
xhr.send(body);
|
||||||
});
|
});
|
||||||
send.abort = () => {
|
send.abort = () => {
|
||||||
console.log(`[Api] Publish aborted by user`);
|
console.log(`[Api] Publish aborted by user`);
|
||||||
xhr.abort();
|
xhr.abort();
|
||||||
}
|
};
|
||||||
return send;
|
return send;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,13 +111,14 @@ class Api {
|
||||||
const url = topicUrlAuth(baseUrl, topic);
|
const url = topicUrlAuth(baseUrl, topic);
|
||||||
console.log(`[Api] Checking auth for ${url}`);
|
console.log(`[Api] Checking auth for ${url}`);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: maybeWithBasicAuth({}, user)
|
headers: maybeWithBasicAuth({}, user),
|
||||||
});
|
});
|
||||||
if (response.status >= 200 && response.status <= 299) {
|
if (response.status >= 200 && response.status <= 299) {
|
||||||
return true;
|
return true;
|
||||||
} else if (!user && response.status === 404) {
|
} else if (!user && response.status === 404) {
|
||||||
return true; // Special case: Anonymous login to old servers return 404 since /<topic>/auth doesn't exist
|
return true; // Special case: Anonymous login to old servers return 404 since /<topic>/auth doesn't exist
|
||||||
} else if (response.status === 401 || response.status === 403) { // See server/server.go
|
} else if (response.status === 401 || response.status === 403) {
|
||||||
|
// See server/server.go
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected server response ${response.status}`);
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
|
|
@ -9,7 +9,16 @@ const retryBackoffSeconds = [5, 10, 15, 20, 30];
|
||||||
* Incoming messages and state changes are forwarded via listeners.
|
* Incoming messages and state changes are forwarded via listeners.
|
||||||
*/
|
*/
|
||||||
class Connection {
|
class Connection {
|
||||||
constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) {
|
constructor(
|
||||||
|
connectionId,
|
||||||
|
subscriptionId,
|
||||||
|
baseUrl,
|
||||||
|
topic,
|
||||||
|
user,
|
||||||
|
since,
|
||||||
|
onNotification,
|
||||||
|
onStateChanged,
|
||||||
|
) {
|
||||||
this.connectionId = connectionId;
|
this.connectionId = connectionId;
|
||||||
this.subscriptionId = subscriptionId;
|
this.subscriptionId = subscriptionId;
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
|
@ -29,55 +38,78 @@ class Connection {
|
||||||
// we don't want to re-trigger the main view re-render potentially hundreds of times.
|
// we don't want to re-trigger the main view re-render potentially hundreds of times.
|
||||||
|
|
||||||
const wsUrl = this.wsUrl();
|
const wsUrl = this.wsUrl();
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`);
|
console.log(
|
||||||
|
`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`,
|
||||||
|
);
|
||||||
|
|
||||||
this.ws = new WebSocket(wsUrl);
|
this.ws = new WebSocket(wsUrl);
|
||||||
this.ws.onopen = (event) => {
|
this.ws.onopen = (event) => {
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event);
|
console.log(
|
||||||
|
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`,
|
||||||
|
event,
|
||||||
|
);
|
||||||
this.retryCount = 0;
|
this.retryCount = 0;
|
||||||
this.onStateChanged(this.subscriptionId, ConnectionState.Connected);
|
this.onStateChanged(this.subscriptionId, ConnectionState.Connected);
|
||||||
}
|
};
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
|
console.log(
|
||||||
|
`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.event === 'open') {
|
if (data.event === "open") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const relevantAndValid =
|
const relevantAndValid =
|
||||||
data.event === 'message' &&
|
data.event === "message" &&
|
||||||
'id' in data &&
|
"id" in data &&
|
||||||
'time' in data &&
|
"time" in data &&
|
||||||
'message' in data;
|
"message" in data;
|
||||||
if (!relevantAndValid) {
|
if (!relevantAndValid) {
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
|
console.log(
|
||||||
|
`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.since = data.id;
|
this.since = data.id;
|
||||||
this.onNotification(this.subscriptionId, data);
|
this.onNotification(this.subscriptionId, data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`);
|
console.log(
|
||||||
|
`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.ws.onclose = (event) => {
|
this.ws.onclose = (event) => {
|
||||||
if (event.wasClean) {
|
if (event.wasClean) {
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
|
console.log(
|
||||||
|
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`,
|
||||||
|
);
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
} else {
|
} else {
|
||||||
const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length-1)];
|
const retrySeconds =
|
||||||
|
retryBackoffSeconds[
|
||||||
|
Math.min(this.retryCount, retryBackoffSeconds.length - 1)
|
||||||
|
];
|
||||||
this.retryCount++;
|
this.retryCount++;
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`);
|
console.log(
|
||||||
|
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`,
|
||||||
|
);
|
||||||
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
|
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
|
||||||
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
|
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.ws.onerror = (event) => {
|
this.ws.onerror = (event) => {
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event);
|
console.log(
|
||||||
|
`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`,
|
||||||
|
event,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`);
|
console.log(
|
||||||
|
`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`,
|
||||||
|
);
|
||||||
const socket = this.ws;
|
const socket = this.ws;
|
||||||
const retryTimeout = this.retryTimeout;
|
const retryTimeout = this.retryTimeout;
|
||||||
if (socket !== null) {
|
if (socket !== null) {
|
||||||
|
@ -96,11 +128,13 @@ class Connection {
|
||||||
params.push(`since=${this.since}`);
|
params.push(`since=${this.since}`);
|
||||||
}
|
}
|
||||||
if (this.user) {
|
if (this.user) {
|
||||||
const auth = encodeBase64Url(basicAuth(this.user.username, this.user.password));
|
const auth = encodeBase64Url(
|
||||||
|
basicAuth(this.user.username, this.user.password),
|
||||||
|
);
|
||||||
params.push(`auth=${auth}`);
|
params.push(`auth=${auth}`);
|
||||||
}
|
}
|
||||||
const wsUrl = topicUrlWs(this.baseUrl, this.topic);
|
const wsUrl = topicUrlWs(this.baseUrl, this.topic);
|
||||||
return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`;
|
return params.length === 0 ? wsUrl : `${wsUrl}?${params.join("&")}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -42,20 +42,25 @@ class ConnectionManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`[ConnectionManager] Refreshing connections`);
|
console.log(`[ConnectionManager] Refreshing connections`);
|
||||||
const subscriptionsWithUsersAndConnectionId = await Promise.all(subscriptions
|
const subscriptionsWithUsersAndConnectionId = await Promise.all(
|
||||||
.map(async s => {
|
subscriptions.map(async (s) => {
|
||||||
const [user] = users.filter(u => u.baseUrl === s.baseUrl);
|
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
|
||||||
const connectionId = await makeConnectionId(s, user);
|
const connectionId = await makeConnectionId(s, user);
|
||||||
return { ...s, user, connectionId };
|
return { ...s, user, connectionId };
|
||||||
}));
|
}),
|
||||||
const targetIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId);
|
);
|
||||||
const deletedIds = Array.from(this.connections.keys()).filter(id => !targetIds.includes(id));
|
const targetIds = subscriptionsWithUsersAndConnectionId.map(
|
||||||
|
(s) => s.connectionId,
|
||||||
|
);
|
||||||
|
const deletedIds = Array.from(this.connections.keys()).filter(
|
||||||
|
(id) => !targetIds.includes(id),
|
||||||
|
);
|
||||||
|
|
||||||
// Create and add new connections
|
// Create and add new connections
|
||||||
subscriptionsWithUsersAndConnectionId.forEach(subscription => {
|
subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
|
||||||
const subscriptionId = subscription.id;
|
const subscriptionId = subscription.id;
|
||||||
const connectionId = subscription.connectionId;
|
const connectionId = subscription.connectionId;
|
||||||
const added = !this.connections.get(connectionId)
|
const added = !this.connections.get(connectionId);
|
||||||
if (added) {
|
if (added) {
|
||||||
const baseUrl = subscription.baseUrl;
|
const baseUrl = subscription.baseUrl;
|
||||||
const topic = subscription.topic;
|
const topic = subscription.topic;
|
||||||
|
@ -68,17 +73,22 @@ class ConnectionManager {
|
||||||
topic,
|
topic,
|
||||||
user,
|
user,
|
||||||
since,
|
since,
|
||||||
(subscriptionId, notification) => this.notificationReceived(subscriptionId, notification),
|
(subscriptionId, notification) =>
|
||||||
(subscriptionId, state) => this.stateChanged(subscriptionId, state)
|
this.notificationReceived(subscriptionId, notification),
|
||||||
|
(subscriptionId, state) => this.stateChanged(subscriptionId, state),
|
||||||
);
|
);
|
||||||
this.connections.set(connectionId, connection);
|
this.connections.set(connectionId, connection);
|
||||||
console.log(`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})`);
|
console.log(
|
||||||
|
`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${
|
||||||
|
user ? user.username : "anonymous"
|
||||||
|
})`,
|
||||||
|
);
|
||||||
connection.start();
|
connection.start();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete old connections
|
// Delete old connections
|
||||||
deletedIds.forEach(id => {
|
deletedIds.forEach((id) => {
|
||||||
console.log(`[ConnectionManager] Closing connection ${id}`);
|
console.log(`[ConnectionManager] Closing connection ${id}`);
|
||||||
const connection = this.connections.get(id);
|
const connection = this.connections.get(id);
|
||||||
this.connections.delete(id);
|
this.connections.delete(id);
|
||||||
|
@ -91,7 +101,10 @@ class ConnectionManager {
|
||||||
try {
|
try {
|
||||||
this.stateListener(subscriptionId, state);
|
this.stateListener(subscriptionId, state);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e);
|
console.error(
|
||||||
|
`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -101,17 +114,20 @@ class ConnectionManager {
|
||||||
try {
|
try {
|
||||||
this.notificationListener(subscriptionId, notification);
|
this.notificationListener(subscriptionId, notification);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e);
|
console.error(
|
||||||
|
`[ConnectionManager] Error handling notification for ${subscriptionId}`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const makeConnectionId = async (subscription, user) => {
|
const makeConnectionId = async (subscription, user) => {
|
||||||
return (user)
|
return user
|
||||||
? hashCode(`${subscription.id}|${user.username}|${user.password}`)
|
? hashCode(`${subscription.id}|${user.username}|${user.password}`)
|
||||||
: hashCode(`${subscription.id}`);
|
: hashCode(`${subscription.id}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
const connectionManager = new ConnectionManager();
|
const connectionManager = new ConnectionManager();
|
||||||
export default connectionManager;
|
export default connectionManager;
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
import {formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl} from "./utils";
|
import {
|
||||||
|
formatMessage,
|
||||||
|
formatTitleWithDefault,
|
||||||
|
openUrl,
|
||||||
|
playSound,
|
||||||
|
topicDisplayName,
|
||||||
|
topicShortUrl,
|
||||||
|
} from "./utils";
|
||||||
import prefs from "./Prefs";
|
import prefs from "./Prefs";
|
||||||
import subscriptionManager from "./SubscriptionManager";
|
import subscriptionManager from "./SubscriptionManager";
|
||||||
import logo from "../img/ntfy.png";
|
import logo from "../img/ntfy.png";
|
||||||
|
@ -23,10 +30,12 @@ class Notifier {
|
||||||
const title = formatTitleWithDefault(notification, displayName);
|
const title = formatTitleWithDefault(notification, displayName);
|
||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
|
console.log(
|
||||||
|
`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`,
|
||||||
|
);
|
||||||
const n = new Notification(title, {
|
const n = new Notification(title, {
|
||||||
body: message,
|
body: message,
|
||||||
icon: logo
|
icon: logo,
|
||||||
});
|
});
|
||||||
if (notification.click) {
|
if (notification.click) {
|
||||||
n.onclick = (e) => openUrl(notification.click);
|
n.onclick = (e) => openUrl(notification.click);
|
||||||
|
@ -46,7 +55,7 @@ class Notifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
granted() {
|
granted() {
|
||||||
return this.supported() && Notification.permission === 'granted';
|
return this.supported() && Notification.permission === "granted";
|
||||||
}
|
}
|
||||||
|
|
||||||
maybeRequestPermission(cb) {
|
maybeRequestPermission(cb) {
|
||||||
|
@ -56,7 +65,7 @@ class Notifier {
|
||||||
}
|
}
|
||||||
if (!this.granted()) {
|
if (!this.granted()) {
|
||||||
Notification.requestPermission().then((permission) => {
|
Notification.requestPermission().then((permission) => {
|
||||||
const granted = permission === 'granted';
|
const granted = permission === "granted";
|
||||||
cb(granted);
|
cb(granted);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -66,7 +75,7 @@ class Notifier {
|
||||||
if (subscription.mutedUntil === 1) {
|
if (subscription.mutedUntil === 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const priority = (notification.priority) ? notification.priority : 3;
|
const priority = notification.priority ? notification.priority : 3;
|
||||||
const minPriority = await prefs.minPriority();
|
const minPriority = await prefs.minPriority();
|
||||||
if (priority < minPriority) {
|
if (priority < minPriority) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -79,7 +88,7 @@ class Notifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
browserSupported() {
|
browserSupported() {
|
||||||
return 'Notification' in window;
|
return "Notification" in window;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -87,9 +96,11 @@ class Notifier {
|
||||||
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
|
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
|
||||||
*/
|
*/
|
||||||
contextSupported() {
|
contextSupported() {
|
||||||
return location.protocol === 'https:'
|
return (
|
||||||
|| location.hostname.match('^127.')
|
location.protocol === "https:" ||
|
||||||
|| location.hostname === 'localhost';
|
location.hostname.match("^127.") ||
|
||||||
|
location.hostname === "localhost"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -34,12 +34,18 @@ class Poller {
|
||||||
console.log(`[Poller] Polling ${subscription.id}`);
|
console.log(`[Poller] Polling ${subscription.id}`);
|
||||||
|
|
||||||
const since = subscription.last;
|
const since = subscription.last;
|
||||||
const notifications = await api.poll(subscription.baseUrl, subscription.topic, since);
|
const notifications = await api.poll(
|
||||||
|
subscription.baseUrl,
|
||||||
|
subscription.topic,
|
||||||
|
since,
|
||||||
|
);
|
||||||
if (!notifications || notifications.length === 0) {
|
if (!notifications || notifications.length === 0) {
|
||||||
console.log(`[Poller] No new notifications found for ${subscription.id}`);
|
console.log(`[Poller] No new notifications found for ${subscription.id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`);
|
console.log(
|
||||||
|
`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`,
|
||||||
|
);
|
||||||
await subscriptionManager.addNotifications(subscription.id, notifications);
|
await subscriptionManager.addNotifications(subscription.id, notifications);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,30 +2,30 @@ import db from "./db";
|
||||||
|
|
||||||
class Prefs {
|
class Prefs {
|
||||||
async setSound(sound) {
|
async setSound(sound) {
|
||||||
db.prefs.put({key: 'sound', value: sound.toString()});
|
db.prefs.put({ key: "sound", value: sound.toString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
async sound() {
|
async sound() {
|
||||||
const sound = await db.prefs.get('sound');
|
const sound = await db.prefs.get("sound");
|
||||||
return (sound) ? sound.value : "ding";
|
return sound ? sound.value : "ding";
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMinPriority(minPriority) {
|
async setMinPriority(minPriority) {
|
||||||
db.prefs.put({key: 'minPriority', value: minPriority.toString()});
|
db.prefs.put({ key: "minPriority", value: minPriority.toString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
async minPriority() {
|
async minPriority() {
|
||||||
const minPriority = await db.prefs.get('minPriority');
|
const minPriority = await db.prefs.get("minPriority");
|
||||||
return (minPriority) ? Number(minPriority.value) : 1;
|
return minPriority ? Number(minPriority.value) : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDeleteAfter(deleteAfter) {
|
async setDeleteAfter(deleteAfter) {
|
||||||
db.prefs.put({key:'deleteAfter', value: deleteAfter.toString()});
|
db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAfter() {
|
async deleteAfter() {
|
||||||
const deleteAfter = await db.prefs.get('deleteAfter');
|
const deleteAfter = await db.prefs.get("deleteAfter");
|
||||||
return (deleteAfter) ? Number(deleteAfter.value) : 604800; // Default is one week
|
return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,12 +20,15 @@ class Pruner {
|
||||||
|
|
||||||
async prune() {
|
async prune() {
|
||||||
const deleteAfterSeconds = await prefs.deleteAfter();
|
const deleteAfterSeconds = await prefs.deleteAfter();
|
||||||
const pruneThresholdTimestamp = Math.round(Date.now()/1000) - deleteAfterSeconds;
|
const pruneThresholdTimestamp =
|
||||||
|
Math.round(Date.now() / 1000) - deleteAfterSeconds;
|
||||||
if (deleteAfterSeconds === 0) {
|
if (deleteAfterSeconds === 0) {
|
||||||
console.log(`[Pruner] Pruning is disabled. Skipping.`);
|
console.log(`[Pruner] Pruning is disabled. Skipping.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`);
|
console.log(
|
||||||
|
`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await subscriptionManager.pruneNotifications(pruneThresholdTimestamp);
|
await subscriptionManager.pruneNotifications(pruneThresholdTimestamp);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
@ -5,16 +5,18 @@ class SubscriptionManager {
|
||||||
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
|
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
|
||||||
async all() {
|
async all() {
|
||||||
const subscriptions = await db.subscriptions.toArray();
|
const subscriptions = await db.subscriptions.toArray();
|
||||||
await Promise.all(subscriptions.map(async s => {
|
await Promise.all(
|
||||||
|
subscriptions.map(async (s) => {
|
||||||
s.new = await db.notifications
|
s.new = await db.notifications
|
||||||
.where({ subscriptionId: s.id, new: 1 })
|
.where({ subscriptionId: s.id, new: 1 })
|
||||||
.count();
|
.count();
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
return subscriptions;
|
return subscriptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(subscriptionId) {
|
async get(subscriptionId) {
|
||||||
return await db.subscriptions.get(subscriptionId)
|
return await db.subscriptions.get(subscriptionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async add(baseUrl, topic) {
|
async add(baseUrl, topic) {
|
||||||
|
@ -23,7 +25,7 @@ class SubscriptionManager {
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
topic: topic,
|
topic: topic,
|
||||||
mutedUntil: 0,
|
mutedUntil: 0,
|
||||||
last: null
|
last: null,
|
||||||
};
|
};
|
||||||
await db.subscriptions.put(subscription);
|
await db.subscriptions.put(subscription);
|
||||||
return subscription;
|
return subscription;
|
||||||
|
@ -35,9 +37,7 @@ class SubscriptionManager {
|
||||||
|
|
||||||
async remove(subscriptionId) {
|
async remove(subscriptionId) {
|
||||||
await db.subscriptions.delete(subscriptionId);
|
await db.subscriptions.delete(subscriptionId);
|
||||||
await db.notifications
|
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
|
||||||
.where({subscriptionId: subscriptionId})
|
|
||||||
.delete();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async first() {
|
async first() {
|
||||||
|
@ -51,7 +51,7 @@ class SubscriptionManager {
|
||||||
|
|
||||||
return db.notifications
|
return db.notifications
|
||||||
.orderBy("time") // Sort by time first
|
.orderBy("time") // Sort by time first
|
||||||
.filter(n => n.subscriptionId === subscriptionId)
|
.filter((n) => n.subscriptionId === subscriptionId)
|
||||||
.reverse()
|
.reverse()
|
||||||
.toArray();
|
.toArray();
|
||||||
}
|
}
|
||||||
|
@ -73,7 +73,7 @@ class SubscriptionManager {
|
||||||
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
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.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await db.subscriptions.update(subscriptionId, {
|
||||||
last: notification.id
|
last: notification.id,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[SubscriptionManager] Error adding notification`, e);
|
console.error(`[SubscriptionManager] Error adding notification`, e);
|
||||||
|
@ -83,12 +83,13 @@ class SubscriptionManager {
|
||||||
|
|
||||||
/** Adds/replaces notifications, will not throw if they exist */
|
/** Adds/replaces notifications, will not throw if they exist */
|
||||||
async addNotifications(subscriptionId, notifications) {
|
async addNotifications(subscriptionId, notifications) {
|
||||||
const notificationsWithSubscriptionId = notifications
|
const notificationsWithSubscriptionId = notifications.map(
|
||||||
.map(notification => ({ ...notification, subscriptionId }));
|
(notification) => ({ ...notification, subscriptionId }),
|
||||||
|
);
|
||||||
const lastNotificationId = notifications.at(-1).id;
|
const lastNotificationId = notifications.at(-1).id;
|
||||||
await db.notifications.bulkPut(notificationsWithSubscriptionId);
|
await db.notifications.bulkPut(notificationsWithSubscriptionId);
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await db.subscriptions.update(subscriptionId, {
|
||||||
last: lastNotificationId
|
last: lastNotificationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,15 +111,11 @@ class SubscriptionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteNotifications(subscriptionId) {
|
async deleteNotifications(subscriptionId) {
|
||||||
await db.notifications
|
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
|
||||||
.where({subscriptionId: subscriptionId})
|
|
||||||
.delete();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async markNotificationRead(notificationId) {
|
async markNotificationRead(notificationId) {
|
||||||
await db.notifications
|
await db.notifications.where({ id: notificationId }).modify({ new: 0 });
|
||||||
.where({id: notificationId})
|
|
||||||
.modify({new: 0});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async markNotificationsRead(subscriptionId) {
|
async markNotificationsRead(subscriptionId) {
|
||||||
|
@ -129,20 +126,18 @@ class SubscriptionManager {
|
||||||
|
|
||||||
async setMutedUntil(subscriptionId, mutedUntil) {
|
async setMutedUntil(subscriptionId, mutedUntil) {
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await db.subscriptions.update(subscriptionId, {
|
||||||
mutedUntil: mutedUntil
|
mutedUntil: mutedUntil,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDisplayName(subscriptionId, displayName) {
|
async setDisplayName(subscriptionId, displayName) {
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await db.subscriptions.update(subscriptionId, {
|
||||||
displayName: displayName
|
displayName: displayName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async pruneNotifications(thresholdTimestamp) {
|
async pruneNotifications(thresholdTimestamp) {
|
||||||
await db.notifications
|
await db.notifications.where("time").below(thresholdTimestamp).delete();
|
||||||
.where("time").below(thresholdTimestamp)
|
|
||||||
.delete();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import Dexie from 'dexie';
|
import Dexie from "dexie";
|
||||||
|
|
||||||
// Uses Dexie.js
|
// Uses Dexie.js
|
||||||
// https://dexie.org/docs/API-Reference#quick-reference
|
// https://dexie.org/docs/API-Reference#quick-reference
|
||||||
|
@ -6,13 +6,13 @@ import Dexie from 'dexie';
|
||||||
// Notes:
|
// Notes:
|
||||||
// - As per docs, we only declare the indexable columns, not all columns
|
// - As per docs, we only declare the indexable columns, not all columns
|
||||||
|
|
||||||
const db = new Dexie('ntfy');
|
const db = new Dexie("ntfy");
|
||||||
|
|
||||||
db.version(1).stores({
|
db.version(1).stores({
|
||||||
subscriptions: '&id,baseUrl',
|
subscriptions: "&id,baseUrl",
|
||||||
notifications: '&id,subscriptionId,time,new,[subscriptionId+new]', // compound key for query performance
|
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
|
||||||
users: '&baseUrl,username',
|
users: "&baseUrl,username",
|
||||||
prefs: '&key'
|
prefs: "&key",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default db;
|
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
|
@ -7,17 +7,23 @@ import dadum from "../sounds/dadum.mp3";
|
||||||
import pop from "../sounds/pop.mp3";
|
import pop from "../sounds/pop.mp3";
|
||||||
import popSwoosh from "../sounds/pop-swoosh.mp3";
|
import popSwoosh from "../sounds/pop-swoosh.mp3";
|
||||||
import config from "./config";
|
import config from "./config";
|
||||||
import {Base64} from 'js-base64';
|
import { Base64 } from "js-base64";
|
||||||
|
|
||||||
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
|
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
|
||||||
export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`
|
export const topicUrlWs = (baseUrl, topic) =>
|
||||||
|
`${topicUrl(baseUrl, topic)}/ws`
|
||||||
.replaceAll("https://", "wss://")
|
.replaceAll("https://", "wss://")
|
||||||
.replaceAll("http://", "ws://");
|
.replaceAll("http://", "ws://");
|
||||||
export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;
|
export const topicUrlJson = (baseUrl, topic) =>
|
||||||
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
|
`${topicUrl(baseUrl, topic)}/json`;
|
||||||
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
export const topicUrlJsonPoll = (baseUrl, topic) =>
|
||||||
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
|
`${topicUrlJson(baseUrl, topic)}?poll=1`;
|
||||||
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) =>
|
||||||
|
`${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
||||||
|
export const topicUrlAuth = (baseUrl, topic) =>
|
||||||
|
`${topicUrl(baseUrl, topic)}/auth`;
|
||||||
|
export const topicShortUrl = (baseUrl, topic) =>
|
||||||
|
shortUrl(topicUrl(baseUrl, topic));
|
||||||
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
|
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
|
||||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||||
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
||||||
|
@ -25,18 +31,18 @@ export const expandSecureUrl = (url) => `https://${url}`;
|
||||||
|
|
||||||
export const validUrl = (url) => {
|
export const validUrl = (url) => {
|
||||||
return url.match(/^https?:\/\/.+/);
|
return url.match(/^https?:\/\/.+/);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const validTopic = (topic) => {
|
export const validTopic = (topic) => {
|
||||||
if (disallowedTopic(topic)) {
|
if (disallowedTopic(topic)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
|
return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
|
||||||
}
|
};
|
||||||
|
|
||||||
export const disallowedTopic = (topic) => {
|
export const disallowedTopic = (topic) => {
|
||||||
return config.disallowedTopics.includes(topic);
|
return config.disallowedTopics.includes(topic);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const topicDisplayName = (subscription) => {
|
export const topicDisplayName = (subscription) => {
|
||||||
if (subscription.displayName) {
|
if (subscription.displayName) {
|
||||||
|
@ -49,16 +55,16 @@ export const topicDisplayName = (subscription) => {
|
||||||
|
|
||||||
// Format emojis (see emoji.js)
|
// Format emojis (see emoji.js)
|
||||||
const emojis = {};
|
const emojis = {};
|
||||||
rawEmojis.forEach(emoji => {
|
rawEmojis.forEach((emoji) => {
|
||||||
emoji.aliases.forEach(alias => {
|
emoji.aliases.forEach((alias) => {
|
||||||
emojis[alias] = emoji.emoji;
|
emojis[alias] = emoji.emoji;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const toEmojis = (tags) => {
|
const toEmojis = (tags) => {
|
||||||
if (!tags) return [];
|
if (!tags) return [];
|
||||||
else return tags.filter(tag => tag in emojis).map(tag => emojis[tag]);
|
else return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const formatTitleWithDefault = (m, fallback) => {
|
export const formatTitleWithDefault = (m, fallback) => {
|
||||||
if (m.title) {
|
if (m.title) {
|
||||||
|
@ -91,39 +97,41 @@ export const formatMessage = (m) => {
|
||||||
|
|
||||||
export const unmatchedTags = (tags) => {
|
export const unmatchedTags = (tags) => {
|
||||||
if (!tags) return [];
|
if (!tags) return [];
|
||||||
else return tags.filter(tag => !(tag in emojis));
|
else return tags.filter((tag) => !(tag in emojis));
|
||||||
}
|
};
|
||||||
|
|
||||||
export const maybeWithBasicAuth = (headers, user) => {
|
export const maybeWithBasicAuth = (headers, user) => {
|
||||||
if (user) {
|
if (user) {
|
||||||
headers['Authorization'] = `Basic ${encodeBase64(`${user.username}:${user.password}`)}`;
|
headers["Authorization"] = `Basic ${encodeBase64(
|
||||||
|
`${user.username}:${user.password}`,
|
||||||
|
)}`;
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const basicAuth = (username, password) => {
|
export const basicAuth = (username, password) => {
|
||||||
return `Basic ${encodeBase64(`${username}:${password}`)}`;
|
return `Basic ${encodeBase64(`${username}:${password}`)}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const encodeBase64 = (s) => {
|
export const encodeBase64 = (s) => {
|
||||||
return Base64.encode(s);
|
return Base64.encode(s);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const encodeBase64Url = (s) => {
|
export const encodeBase64Url = (s) => {
|
||||||
return Base64.encodeURI(s);
|
return Base64.encodeURI(s);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const maybeAppendActionErrors = (message, notification) => {
|
export const maybeAppendActionErrors = (message, notification) => {
|
||||||
const actionErrors = (notification.actions ?? [])
|
const actionErrors = (notification.actions ?? [])
|
||||||
.map(action => action.error)
|
.map((action) => action.error)
|
||||||
.filter(action => !!action)
|
.filter((action) => !!action)
|
||||||
.join("\n")
|
.join("\n");
|
||||||
if (actionErrors.length === 0) {
|
if (actionErrors.length === 0) {
|
||||||
return message;
|
return message;
|
||||||
} else {
|
} else {
|
||||||
return `${message}\n\n${actionErrors}`;
|
return `${message}\n\n${actionErrors}`;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const shuffle = (arr) => {
|
export const shuffle = (arr) => {
|
||||||
let j, x;
|
let j, x;
|
||||||
|
@ -134,73 +142,75 @@ export const shuffle = (arr) => {
|
||||||
arr[j] = x;
|
arr[j] = x;
|
||||||
}
|
}
|
||||||
return arr;
|
return arr;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const splitNoEmpty = (s, delimiter) => {
|
export const splitNoEmpty = (s, delimiter) => {
|
||||||
return s
|
return s
|
||||||
.split(delimiter)
|
.split(delimiter)
|
||||||
.map(x => x.trim())
|
.map((x) => x.trim())
|
||||||
.filter(x => x !== "");
|
.filter((x) => x !== "");
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
|
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
|
||||||
export const hashCode = async (s) => {
|
export const hashCode = async (s) => {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < s.length; i++) {
|
for (let i = 0; i < s.length; i++) {
|
||||||
const char = s.charCodeAt(i);
|
const char = s.charCodeAt(i);
|
||||||
hash = ((hash<<5)-hash)+char;
|
hash = (hash << 5) - hash + char;
|
||||||
hash = hash & hash; // Convert to 32bit integer
|
hash = hash & hash; // Convert to 32bit integer
|
||||||
}
|
}
|
||||||
return hash;
|
return hash;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const formatShortDateTime = (timestamp) => {
|
export const formatShortDateTime = (timestamp) => {
|
||||||
return new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'})
|
return new Intl.DateTimeFormat("default", {
|
||||||
.format(new Date(timestamp * 1000));
|
dateStyle: "short",
|
||||||
}
|
timeStyle: "short",
|
||||||
|
}).format(new Date(timestamp * 1000));
|
||||||
|
};
|
||||||
|
|
||||||
export const formatBytes = (bytes, decimals = 2) => {
|
export const formatBytes = (bytes, decimals = 2) => {
|
||||||
if (bytes === 0) return '0 bytes';
|
if (bytes === 0) return "0 bytes";
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||||
}
|
};
|
||||||
|
|
||||||
export const openUrl = (url) => {
|
export const openUrl = (url) => {
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sounds = {
|
export const sounds = {
|
||||||
"ding": {
|
ding: {
|
||||||
file: ding,
|
file: ding,
|
||||||
label: "Ding"
|
label: "Ding",
|
||||||
},
|
},
|
||||||
"juntos": {
|
juntos: {
|
||||||
file: juntos,
|
file: juntos,
|
||||||
label: "Juntos"
|
label: "Juntos",
|
||||||
},
|
},
|
||||||
"pristine": {
|
pristine: {
|
||||||
file: pristine,
|
file: pristine,
|
||||||
label: "Pristine"
|
label: "Pristine",
|
||||||
},
|
},
|
||||||
"dadum": {
|
dadum: {
|
||||||
file: dadum,
|
file: dadum,
|
||||||
label: "Dadum"
|
label: "Dadum",
|
||||||
},
|
},
|
||||||
"pop": {
|
pop: {
|
||||||
file: pop,
|
file: pop,
|
||||||
label: "Pop"
|
label: "Pop",
|
||||||
},
|
},
|
||||||
"pop-swoosh": {
|
"pop-swoosh": {
|
||||||
file: popSwoosh,
|
file: popSwoosh,
|
||||||
label: "Pop swoosh"
|
label: "Pop swoosh",
|
||||||
},
|
},
|
||||||
"beep": {
|
beep: {
|
||||||
file: beep,
|
file: beep,
|
||||||
label: "Beep"
|
label: "Beep",
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playSound = async (id) => {
|
export const playSound = async (id) => {
|
||||||
|
@ -210,13 +220,13 @@ export const playSound = async (id) => {
|
||||||
|
|
||||||
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
||||||
export async function* fetchLinesIterator(fileURL, headers) {
|
export async function* fetchLinesIterator(fileURL, headers) {
|
||||||
const utf8Decoder = new TextDecoder('utf-8');
|
const utf8Decoder = new TextDecoder("utf-8");
|
||||||
const response = await fetch(fileURL, {
|
const response = await fetch(fileURL, {
|
||||||
headers: headers
|
headers: headers,
|
||||||
});
|
});
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
let { value: chunk, done: readerDone } = await reader.read();
|
let { value: chunk, done: readerDone } = await reader.read();
|
||||||
chunk = chunk ? utf8Decoder.decode(chunk) : '';
|
chunk = chunk ? utf8Decoder.decode(chunk) : "";
|
||||||
|
|
||||||
const re = /\n|\r|\r\n/gm;
|
const re = /\n|\r|\r\n/gm;
|
||||||
let startIndex = 0;
|
let startIndex = 0;
|
||||||
|
@ -229,7 +239,7 @@ export async function* fetchLinesIterator(fileURL, headers) {
|
||||||
}
|
}
|
||||||
let remainder = chunk.substr(startIndex);
|
let remainder = chunk.substr(startIndex);
|
||||||
({ value: chunk, done: readerDone } = await reader.read());
|
({ value: chunk, done: readerDone } = await reader.read());
|
||||||
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : '');
|
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : "");
|
||||||
startIndex = re.lastIndex = 0;
|
startIndex = re.lastIndex = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -242,10 +252,11 @@ export async function* fetchLinesIterator(fileURL, headers) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const randomAlphanumericString = (len) => {
|
export const randomAlphanumericString = (len) => {
|
||||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
const alphabet =
|
||||||
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
let id = "";
|
let id = "";
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
id += alphabet[(Math.random() * alphabet.length) | 0];
|
id += alphabet[(Math.random() * alphabet.length) | 0];
|
||||||
}
|
}
|
||||||
return id;
|
return id;
|
||||||
}
|
};
|
||||||
|
|
|
@ -7,17 +7,22 @@ import Typography from "@mui/material/Typography";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import {formatShortDateTime, shuffle, topicDisplayName, topicShortUrl} from "../app/utils";
|
import {
|
||||||
|
formatShortDateTime,
|
||||||
|
shuffle,
|
||||||
|
topicDisplayName,
|
||||||
|
topicShortUrl,
|
||||||
|
} from "../app/utils";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
import ClickAwayListener from "@mui/material/ClickAwayListener";
|
||||||
import Grow from '@mui/material/Grow';
|
import Grow from "@mui/material/Grow";
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from "@mui/material/Paper";
|
||||||
import Popper from '@mui/material/Popper';
|
import Popper from "@mui/material/Popper";
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import MenuList from '@mui/material/MenuList';
|
import MenuList from "@mui/material/MenuList";
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
import NotificationsIcon from "@mui/icons-material/Notifications";
|
||||||
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
|
import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
|
||||||
import api from "../app/Api";
|
import api from "../app/Api";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
|
@ -36,18 +41,21 @@ const ActionBar = (props) => {
|
||||||
title = t("action_bar_settings");
|
title = t("action_bar_settings");
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<AppBar position="fixed" sx={{
|
<AppBar
|
||||||
width: '100%',
|
position="fixed"
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
|
zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
|
||||||
ml: { sm: `${Navigation.width}px` }
|
ml: { sm: `${Navigation.width}px` },
|
||||||
}}>
|
}}
|
||||||
<Toolbar sx={{pr: '24px'}}>
|
>
|
||||||
|
<Toolbar sx={{ pr: "24px" }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
edge="start"
|
edge="start"
|
||||||
aria-label={t("action_bar_show_menu")}
|
aria-label={t("action_bar_show_menu")}
|
||||||
onClick={props.onMobileDrawerToggle}
|
onClick={props.onMobileDrawerToggle}
|
||||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
sx={{ mr: 2, display: { sm: "none" } }}
|
||||||
>
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@ -56,19 +64,20 @@ const ActionBar = (props) => {
|
||||||
src={logo}
|
src={logo}
|
||||||
alt={t("action_bar_logo_alt")}
|
alt={t("action_bar_logo_alt")}
|
||||||
sx={{
|
sx={{
|
||||||
display: { xs: 'none', sm: 'block' },
|
display: { xs: "none", sm: "block" },
|
||||||
marginRight: '10px',
|
marginRight: "10px",
|
||||||
height: '28px'
|
height: "28px",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{props.selected &&
|
{props.selected && (
|
||||||
<SettingsIcons
|
<SettingsIcons
|
||||||
subscription={props.selected}
|
subscription={props.selected}
|
||||||
onUnsubscribe={props.onUnsubscribe}
|
onUnsubscribe={props.onUnsubscribe}
|
||||||
/>}
|
/>
|
||||||
|
)}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
);
|
);
|
||||||
|
@ -80,7 +89,8 @@ const SettingsIcons = (props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [snackOpen, setSnackOpen] = useState(false);
|
const [snackOpen, setSnackOpen] = useState(false);
|
||||||
const [subscriptionSettingsOpen, setSubscriptionSettingsOpen] = useState(false);
|
const [subscriptionSettingsOpen, setSubscriptionSettingsOpen] =
|
||||||
|
useState(false);
|
||||||
const anchorRef = useRef(null);
|
const anchorRef = useRef(null);
|
||||||
const subscription = props.subscription;
|
const subscription = props.subscription;
|
||||||
|
|
||||||
|
@ -89,9 +99,9 @@ const SettingsIcons = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleMute = async () => {
|
const handleToggleMute = async () => {
|
||||||
const mutedUntil = (subscription.mutedUntil) ? 0 : 1; // Make this a timestamp in the future
|
const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future
|
||||||
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
|
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleClose = (event) => {
|
const handleClose = (event) => {
|
||||||
if (anchorRef.current && anchorRef.current.contains(event.target)) {
|
if (anchorRef.current && anchorRef.current.contains(event.target)) {
|
||||||
|
@ -102,7 +112,9 @@ const SettingsIcons = (props) => {
|
||||||
|
|
||||||
const handleClearAll = async (event) => {
|
const handleClearAll = async (event) => {
|
||||||
handleClose(event);
|
handleClose(event);
|
||||||
console.log(`[ActionBar] Deleting all notifications from ${props.subscription.id}`);
|
console.log(
|
||||||
|
`[ActionBar] Deleting all notifications from ${props.subscription.id}`,
|
||||||
|
);
|
||||||
await subscriptionManager.deleteNotifications(props.subscription.id);
|
await subscriptionManager.deleteNotifications(props.subscription.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -120,15 +132,30 @@ const SettingsIcons = (props) => {
|
||||||
|
|
||||||
const handleSubscriptionSettings = async () => {
|
const handleSubscriptionSettings = async () => {
|
||||||
setSubscriptionSettingsOpen(true);
|
setSubscriptionSettingsOpen(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSendTestMessage = async () => {
|
const handleSendTestMessage = async () => {
|
||||||
const baseUrl = props.subscription.baseUrl;
|
const baseUrl = props.subscription.baseUrl;
|
||||||
const topic = props.subscription.topic;
|
const topic = props.subscription.topic;
|
||||||
const tags = shuffle([
|
const tags = shuffle([
|
||||||
"grinning", "octopus", "upside_down_face", "palm_tree", "maple_leaf", "apple", "skull", "warning", "jack_o_lantern",
|
"grinning",
|
||||||
"de-server-1", "backups", "cron-script", "script-error", "phils-automation", "mouse", "go-rocks", "hi-ben"])
|
"octopus",
|
||||||
.slice(0, Math.round(Math.random() * 4));
|
"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 priority = shuffle([1, 2, 3, 4, 5])[0];
|
||||||
const title = shuffle([
|
const title = shuffle([
|
||||||
"",
|
"",
|
||||||
|
@ -140,39 +167,43 @@ const SettingsIcons = (props) => {
|
||||||
"I don't really like apples",
|
"I don't really like apples",
|
||||||
"My favorite TV show is The Wire. You should watch it!",
|
"My favorite TV show is The Wire. You should watch it!",
|
||||||
"You can attach files and URLs to messages too",
|
"You can attach files and URLs to messages too",
|
||||||
"You can delay messages up to 3 days"
|
"You can delay messages up to 3 days",
|
||||||
])[0];
|
])[0];
|
||||||
const nowSeconds = Math.round(Date.now() / 1000);
|
const nowSeconds = Math.round(Date.now() / 1000);
|
||||||
const message = shuffle([
|
const message = shuffle([
|
||||||
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
|
`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.`,
|
`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.`,
|
`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.`,
|
`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 ...`,
|
`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.`,
|
`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?`
|
`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];
|
])[0];
|
||||||
try {
|
try {
|
||||||
await api.publish(baseUrl, topic, message, {
|
await api.publish(baseUrl, topic, message, {
|
||||||
title: title,
|
title: title,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
tags: tags
|
tags: tags,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[ActionBar] Error publishing message`, e);
|
console.log(`[ActionBar] Error publishing message`, e);
|
||||||
setSnackOpen(true);
|
setSnackOpen(true);
|
||||||
}
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleListKeyDown = (event) => {
|
const handleListKeyDown = (event) => {
|
||||||
if (event.key === 'Tab') {
|
if (event.key === "Tab") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} else if (event.key === 'Escape') {
|
} else if (event.key === "Escape") {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// return focus to the button when we transitioned from !open -> open
|
// return focus to the button when we transitioned from !open -> open
|
||||||
const prevOpen = useRef(open);
|
const prevOpen = useRef(open);
|
||||||
|
@ -185,10 +216,28 @@ const SettingsIcons = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} sx={{marginRight: 0}} aria-label={t("action_bar_toggle_mute")}>
|
<IconButton
|
||||||
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
|
color="inherit"
|
||||||
|
size="large"
|
||||||
|
edge="end"
|
||||||
|
onClick={handleToggleMute}
|
||||||
|
sx={{ marginRight: 0 }}
|
||||||
|
aria-label={t("action_bar_toggle_mute")}
|
||||||
|
>
|
||||||
|
{subscription.mutedUntil ? (
|
||||||
|
<NotificationsOffIcon />
|
||||||
|
) : (
|
||||||
|
<NotificationsIcon />
|
||||||
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}>
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
size="large"
|
||||||
|
edge="end"
|
||||||
|
ref={anchorRef}
|
||||||
|
onClick={handleToggleOpen}
|
||||||
|
aria-label={t("action_bar_toggle_action_menu")}
|
||||||
|
>
|
||||||
<MoreVertIcon />
|
<MoreVertIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Popper
|
<Popper
|
||||||
|
@ -202,15 +251,26 @@ const SettingsIcons = (props) => {
|
||||||
{({ TransitionProps, placement }) => (
|
{({ TransitionProps, placement }) => (
|
||||||
<Grow
|
<Grow
|
||||||
{...TransitionProps}
|
{...TransitionProps}
|
||||||
style={{transformOrigin: placement === 'bottom-start' ? 'left top' : 'left bottom'}}
|
style={{
|
||||||
|
transformOrigin:
|
||||||
|
placement === "bottom-start" ? "left top" : "left bottom",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Paper>
|
<Paper>
|
||||||
<ClickAwayListener onClickAway={handleClose}>
|
<ClickAwayListener onClickAway={handleClose}>
|
||||||
<MenuList autoFocusItem={open} onKeyDown={handleListKeyDown}>
|
<MenuList autoFocusItem={open} onKeyDown={handleListKeyDown}>
|
||||||
<MenuItem onClick={handleSubscriptionSettings}>{t("action_bar_subscription_settings")}</MenuItem>
|
<MenuItem onClick={handleSubscriptionSettings}>
|
||||||
<MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
|
{t("action_bar_subscription_settings")}
|
||||||
<MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</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>
|
||||||
</MenuList>
|
</MenuList>
|
||||||
</ClickAwayListener>
|
</ClickAwayListener>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import {useEffect, useState} from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import Box from '@mui/material/Box';
|
import Box from "@mui/material/Box";
|
||||||
import {ThemeProvider} from '@mui/material/styles';
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import CssBaseline from "@mui/material/CssBaseline";
|
||||||
import Toolbar from '@mui/material/Toolbar';
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
import Notifications from "./Notifications";
|
import Notifications from "./Notifications";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import Navigation from "./Navigation";
|
import Navigation from "./Navigation";
|
||||||
|
@ -14,11 +14,22 @@ import Preferences from "./Preferences";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import userManager from "../app/UserManager";
|
import userManager from "../app/UserManager";
|
||||||
import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from "react-router-dom";
|
import {
|
||||||
|
BrowserRouter,
|
||||||
|
Outlet,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useOutletContext,
|
||||||
|
useParams,
|
||||||
|
} from "react-router-dom";
|
||||||
import { expandUrl } from "../app/utils";
|
import { expandUrl } from "../app/utils";
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import {useAutoSubscribe, useBackgroundProcesses, useConnectionListeners} from "./hooks";
|
import {
|
||||||
|
useAutoSubscribe,
|
||||||
|
useBackgroundProcesses,
|
||||||
|
useConnectionListeners,
|
||||||
|
} from "./hooks";
|
||||||
import PublishDialog from "./PublishDialog";
|
import PublishDialog from "./PublishDialog";
|
||||||
import Messaging from "./Messaging";
|
import Messaging from "./Messaging";
|
||||||
import "./i18n"; // Translations!
|
import "./i18n"; // Translations!
|
||||||
|
@ -38,8 +49,14 @@ const App = () => {
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path={routes.root} element={<AllSubscriptions />} />
|
<Route path={routes.root} element={<AllSubscriptions />} />
|
||||||
<Route path={routes.settings} element={<Preferences />} />
|
<Route path={routes.settings} element={<Preferences />} />
|
||||||
<Route path={routes.subscription} element={<SingleSubscription/>}/>
|
<Route
|
||||||
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
|
path={routes.subscription}
|
||||||
|
element={<SingleSubscription />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={routes.subscriptionExternal}
|
||||||
|
element={<SingleSubscription />}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
@ -47,7 +64,7 @@ const App = () => {
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const AllSubscriptions = () => {
|
const AllSubscriptions = () => {
|
||||||
const { subscriptions } = useOutletContext();
|
const { subscriptions } = useOutletContext();
|
||||||
|
@ -63,14 +80,21 @@ const SingleSubscription = () => {
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||||
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
|
const [notificationsGranted, setNotificationsGranted] = useState(
|
||||||
|
notifier.granted(),
|
||||||
|
);
|
||||||
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
||||||
const users = useLiveQuery(() => userManager.all());
|
const users = useLiveQuery(() => userManager.all());
|
||||||
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
||||||
const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
const newNotificationsCount =
|
||||||
const [selected] = (subscriptions || []).filter(s => {
|
subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
||||||
return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic)
|
const [selected] = (subscriptions || []).filter((s) => {
|
||||||
|| (window.location.origin === s.baseUrl && params.topic === s.topic)
|
return (
|
||||||
|
(params.baseUrl &&
|
||||||
|
expandUrl(params.baseUrl).includes(s.baseUrl) &&
|
||||||
|
params.topic === s.topic) ||
|
||||||
|
(window.location.origin === s.baseUrl && params.topic === s.topic)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
useConnectionListeners(subscriptions, users);
|
useConnectionListeners(subscriptions, users);
|
||||||
|
@ -78,7 +102,7 @@ const Layout = () => {
|
||||||
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{display: 'flex'}}>
|
<Box sx={{ display: "flex" }}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<ActionBar
|
<ActionBar
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
@ -91,7 +115,9 @@ const Layout = () => {
|
||||||
mobileDrawerOpen={mobileDrawerOpen}
|
mobileDrawerOpen={mobileDrawerOpen}
|
||||||
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
||||||
onNotificationGranted={setNotificationsGranted}
|
onNotificationGranted={setNotificationsGranted}
|
||||||
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
|
onPublishMessageClick={() =>
|
||||||
|
setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Main>
|
<Main>
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
|
@ -104,7 +130,7 @@ const Layout = () => {
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const Main = (props) => {
|
const Main = (props) => {
|
||||||
return (
|
return (
|
||||||
|
@ -112,14 +138,17 @@ const Main = (props) => {
|
||||||
id="main"
|
id="main"
|
||||||
component="main"
|
component="main"
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
padding: 3,
|
padding: 3,
|
||||||
width: { sm: `calc(100% - ${Navigation.width}px)` },
|
width: { sm: `calc(100% - ${Navigation.width}px)` },
|
||||||
height: '100vh',
|
height: "100vh",
|
||||||
overflow: 'auto',
|
overflow: "auto",
|
||||||
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
|
backgroundColor: (theme) =>
|
||||||
|
theme.palette.mode === "light"
|
||||||
|
? theme.palette.grey[100]
|
||||||
|
: theme.palette.grey[900],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
@ -132,7 +161,10 @@ const Loader = () => (
|
||||||
open={true}
|
open={true}
|
||||||
sx={{
|
sx={{
|
||||||
zIndex: 100000,
|
zIndex: 100000,
|
||||||
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
|
backgroundColor: (theme) =>
|
||||||
|
theme.palette.mode === "light"
|
||||||
|
? theme.palette.grey[100]
|
||||||
|
: theme.palette.grey[900],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CircularProgress color="success" disableShrink />
|
<CircularProgress color="success" disableShrink />
|
||||||
|
@ -140,7 +172,8 @@ const Loader = () => (
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateTitle = (newNotificationsCount) => {
|
const updateTitle = (newNotificationsCount) => {
|
||||||
document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy";
|
document.title =
|
||||||
}
|
newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
|
||||||
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
@ -14,13 +14,13 @@ const AttachmentIcon = (props) => {
|
||||||
if (!type) {
|
if (!type) {
|
||||||
imageFile = fileDocument;
|
imageFile = fileDocument;
|
||||||
imageLabel = t("notifications_attachment_file_image");
|
imageLabel = t("notifications_attachment_file_image");
|
||||||
} else if (type.startsWith('image/')) {
|
} else if (type.startsWith("image/")) {
|
||||||
imageFile = fileImage;
|
imageFile = fileImage;
|
||||||
imageLabel = t("notifications_attachment_file_video");
|
imageLabel = t("notifications_attachment_file_video");
|
||||||
} else if (type.startsWith('video/')) {
|
} else if (type.startsWith("video/")) {
|
||||||
imageFile = fileVideo;
|
imageFile = fileVideo;
|
||||||
imageLabel = t("notifications_attachment_file_video");
|
imageLabel = t("notifications_attachment_file_video");
|
||||||
} else if (type.startsWith('audio/')) {
|
} else if (type.startsWith("audio/")) {
|
||||||
imageFile = fileAudio;
|
imageFile = fileAudio;
|
||||||
imageLabel = t("notifications_attachment_file_audio");
|
imageLabel = t("notifications_attachment_file_audio");
|
||||||
} else if (type === "application/vnd.android.package-archive") {
|
} else if (type === "application/vnd.android.package-archive") {
|
||||||
|
@ -37,11 +37,11 @@ const AttachmentIcon = (props) => {
|
||||||
alt={imageLabel}
|
alt={imageLabel}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
sx={{
|
sx={{
|
||||||
width: '28px',
|
width: "28px",
|
||||||
height: '28px'
|
height: "28px",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default AttachmentIcon;
|
export default AttachmentIcon;
|
||||||
|
|
|
@ -5,27 +5,27 @@ import DialogActions from "@mui/material/DialogActions";
|
||||||
|
|
||||||
const DialogFooter = (props) => {
|
const DialogFooter = (props) => {
|
||||||
return (
|
return (
|
||||||
<Box sx={{
|
<Box
|
||||||
display: 'flex',
|
sx={{
|
||||||
flexDirection: 'row',
|
display: "flex",
|
||||||
justifyContent: 'space-between',
|
flexDirection: "row",
|
||||||
paddingLeft: '24px',
|
justifyContent: "space-between",
|
||||||
paddingBottom: '8px',
|
paddingLeft: "24px",
|
||||||
}}>
|
paddingBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContentText
|
<DialogContentText
|
||||||
component="div"
|
component="div"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
sx={{
|
sx={{
|
||||||
margin: '0px',
|
margin: "0px",
|
||||||
paddingTop: '12px',
|
paddingTop: "12px",
|
||||||
paddingBottom: '4px'
|
paddingBottom: "4px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.status}
|
{props.status}
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
<DialogActions sx={{paddingRight: 2}}>
|
<DialogActions sx={{ paddingRight: 2 }}>{props.children}</DialogActions>
|
||||||
{props.children}
|
|
||||||
</DialogActions>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import {useRef, useState} from 'react';
|
import { useRef, useState } from "react";
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from "@mui/material/Typography";
|
||||||
import {rawEmojis} from '../app/emojis';
|
import { rawEmojis } from "../app/emojis";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { ClickAwayListener, Fade, InputAdornment, styled } from "@mui/material";
|
import { ClickAwayListener, Fade, InputAdornment, styled } from "@mui/material";
|
||||||
|
@ -17,17 +17,21 @@ import {useTranslation} from "react-i18next";
|
||||||
// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported.
|
// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported.
|
||||||
|
|
||||||
const emojisByCategory = {};
|
const emojisByCategory = {};
|
||||||
const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);
|
const isDesktopChrome =
|
||||||
|
/Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);
|
||||||
const maxSupportedVersionForDesktopChrome = 11;
|
const maxSupportedVersionForDesktopChrome = 11;
|
||||||
rawEmojis.forEach(emoji => {
|
rawEmojis.forEach((emoji) => {
|
||||||
if (!emojisByCategory[emoji.category]) {
|
if (!emojisByCategory[emoji.category]) {
|
||||||
emojisByCategory[emoji.category] = [];
|
emojisByCategory[emoji.category] = [];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const unicodeVersion = parseFloat(emoji.unicode_version);
|
const unicodeVersion = parseFloat(emoji.unicode_version);
|
||||||
const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
|
const supportedEmoji =
|
||||||
|
unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
|
||||||
if (supportedEmoji) {
|
if (supportedEmoji) {
|
||||||
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`;
|
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(
|
||||||
|
" ",
|
||||||
|
)} ${emoji.tags.join(" ")}`;
|
||||||
const emojiWithSearchBase = { ...emoji, searchBase: searchBase };
|
const emojiWithSearchBase = { ...emoji, searchBase: searchBase };
|
||||||
emojisByCategory[emoji.category].push(emojiWithSearchBase);
|
emojisByCategory[emoji.category].push(emojiWithSearchBase);
|
||||||
}
|
}
|
||||||
|
@ -59,42 +63,60 @@ const EmojiPicker = (props) => {
|
||||||
{({ TransitionProps }) => (
|
{({ TransitionProps }) => (
|
||||||
<ClickAwayListener onClickAway={props.onClose}>
|
<ClickAwayListener onClickAway={props.onClose}>
|
||||||
<Fade {...TransitionProps} timeout={350}>
|
<Fade {...TransitionProps} timeout={350}>
|
||||||
<Box sx={{
|
<Box
|
||||||
|
sx={{
|
||||||
boxShadow: 3,
|
boxShadow: 3,
|
||||||
padding: 2,
|
padding: 2,
|
||||||
paddingRight: 0,
|
paddingRight: 0,
|
||||||
paddingBottom: 1,
|
paddingBottom: 1,
|
||||||
width: "380px",
|
width: "380px",
|
||||||
maxHeight: "300px",
|
maxHeight: "300px",
|
||||||
backgroundColor: 'background.paper',
|
backgroundColor: "background.paper",
|
||||||
overflowY: "scroll"
|
overflowY: "scroll",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
inputRef={searchRef}
|
inputRef={searchRef}
|
||||||
margin="dense"
|
margin="dense"
|
||||||
size="small"
|
size="small"
|
||||||
placeholder={t("emoji_picker_search_placeholder")}
|
placeholder={t("emoji_picker_search_placeholder")}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={ev => setSearch(ev.target.value)}
|
onChange={(ev) => setSearch(ev.target.value)}
|
||||||
type="text"
|
type="text"
|
||||||
variant="standard"
|
variant="standard"
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
|
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
role: "searchbox",
|
role: "searchbox",
|
||||||
"aria-label": t("emoji_picker_search_placeholder")
|
"aria-label": t("emoji_picker_search_placeholder"),
|
||||||
}}
|
}}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment:
|
endAdornment: (
|
||||||
<InputAdornment position="end" sx={{ display: (search) ? '' : 'none' }}>
|
<InputAdornment
|
||||||
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}>
|
position="end"
|
||||||
|
sx={{ display: search ? "" : "none" }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={handleSearchClear}
|
||||||
|
edge="end"
|
||||||
|
aria-label={t("emoji_picker_search_clear")}
|
||||||
|
>
|
||||||
<Close />
|
<Close />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ display: "flex", flexWrap: "wrap", paddingRight: 0, marginTop: 1 }}>
|
<Box
|
||||||
{Object.keys(emojisByCategory).map(category =>
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
paddingRight: 0,
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.keys(emojisByCategory).map((category) => (
|
||||||
<Category
|
<Category
|
||||||
key={category}
|
key={category}
|
||||||
title={category}
|
title={category}
|
||||||
|
@ -102,7 +124,7 @@ const EmojiPicker = (props) => {
|
||||||
search={searchFields}
|
search={searchFields}
|
||||||
onPick={props.onEmojiPick}
|
onPick={props.onEmojiPick}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
|
@ -116,19 +138,19 @@ const Category = (props) => {
|
||||||
const showTitle = props.search.length === 0;
|
const showTitle = props.search.length === 0;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showTitle &&
|
{showTitle && (
|
||||||
<Typography variant="body1" sx={{ width: "100%", marginBottom: 1 }}>
|
<Typography variant="body1" sx={{ width: "100%", marginBottom: 1 }}>
|
||||||
{props.title}
|
{props.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
)}
|
||||||
{props.emojis.map(emoji =>
|
{props.emojis.map((emoji) => (
|
||||||
<Emoji
|
<Emoji
|
||||||
key={emoji.aliases[0]}
|
key={emoji.aliases[0]}
|
||||||
emoji={emoji}
|
emoji={emoji}
|
||||||
search={props.search}
|
search={props.search}
|
||||||
onClick={() => props.onPick(emoji.aliases[0])}
|
onClick={() => props.onPick(emoji.aliases[0])}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -142,7 +164,7 @@ const Emoji = (props) => {
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
title={title}
|
title={title}
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
style={{ display: (matches) ? '' : 'none' }}
|
style={{ display: matches ? "" : "none" }}
|
||||||
>
|
>
|
||||||
{props.emoji.emoji}
|
{props.emoji.emoji}
|
||||||
</EmojiDiv>
|
</EmojiDiv>
|
||||||
|
@ -160,8 +182,8 @@ const EmojiDiv = styled("div")({
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
opacity: 0.85,
|
opacity: 0.85,
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
opacity: 1
|
opacity: 1,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emojiMatches = (emoji, words) => {
|
const emojiMatches = (emoji, words) => {
|
||||||
|
@ -174,6 +196,6 @@ const emojiMatches = (emoji, words) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default EmojiPicker;
|
export default EmojiPicker;
|
||||||
|
|
|
@ -11,7 +11,7 @@ class ErrorBoundaryImpl extends React.Component {
|
||||||
error: false,
|
error: false,
|
||||||
originalStack: null,
|
originalStack: null,
|
||||||
niceStack: null,
|
niceStack: null,
|
||||||
unsupportedIndexedDB: false
|
unsupportedIndexedDB: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,8 +21,10 @@ class ErrorBoundaryImpl extends React.Component {
|
||||||
// Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see
|
// Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see
|
||||||
// - https://github.com/dexie/Dexie.js/issues/312
|
// - https://github.com/dexie/Dexie.js/issues/312
|
||||||
// - https://bugzilla.mozilla.org/show_bug.cgi?id=781982
|
// - https://bugzilla.mozilla.org/show_bug.cgi?id=781982
|
||||||
const isUnsupportedIndexedDB = error?.name === "InvalidStateError" ||
|
const isUnsupportedIndexedDB =
|
||||||
(error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1);
|
error?.name === "InvalidStateError" ||
|
||||||
|
(error?.name === "DatabaseClosedError" &&
|
||||||
|
error?.message?.indexOf("InvalidStateError") !== -1);
|
||||||
|
|
||||||
if (isUnsupportedIndexedDB) {
|
if (isUnsupportedIndexedDB) {
|
||||||
this.handleUnsupportedIndexedDB();
|
this.handleUnsupportedIndexedDB();
|
||||||
|
@ -36,17 +38,24 @@ class ErrorBoundaryImpl extends React.Component {
|
||||||
const prettierOriginalStack = info.componentStack
|
const prettierOriginalStack = info.componentStack
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map(line => ` at ${line}`)
|
.map((line) => ` at ${line}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
this.setState({
|
this.setState({
|
||||||
error: true,
|
error: true,
|
||||||
originalStack: `${error.toString()}\n${prettierOriginalStack}`
|
originalStack: `${error.toString()}\n${prettierOriginalStack}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch additional info and a better stack trace
|
// Fetch additional info and a better stack trace
|
||||||
StackTrace.fromError(error).then(stack => {
|
StackTrace.fromError(error).then((stack) => {
|
||||||
console.error("[ErrorBoundary] Stacktrace fetched", stack);
|
console.error("[ErrorBoundary] Stacktrace fetched", stack);
|
||||||
const niceStack = `${error.toString()}\n` + stack.map( el => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
|
const niceStack =
|
||||||
|
`${error.toString()}\n` +
|
||||||
|
stack
|
||||||
|
.map(
|
||||||
|
(el) =>
|
||||||
|
` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
this.setState({ niceStack });
|
this.setState({ niceStack });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -54,7 +63,7 @@ class ErrorBoundaryImpl extends React.Component {
|
||||||
handleUnsupportedIndexedDB() {
|
handleUnsupportedIndexedDB() {
|
||||||
this.setState({
|
this.setState({
|
||||||
error: true,
|
error: true,
|
||||||
unsupportedIndexedDB: true
|
unsupportedIndexedDB: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,15 +90,17 @@ class ErrorBoundaryImpl extends React.Component {
|
||||||
renderUnsupportedIndexedDB() {
|
renderUnsupportedIndexedDB() {
|
||||||
const { t } = this.props;
|
const { t } = this.props;
|
||||||
return (
|
return (
|
||||||
<div style={{margin: '20px'}}>
|
<div style={{ margin: "20px" }}>
|
||||||
<h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2>
|
<h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2>
|
||||||
<p style={{ maxWidth: "600px" }}>
|
<p style={{ maxWidth: "600px" }}>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="error_boundary_unsupported_indexeddb_description"
|
i18nKey="error_boundary_unsupported_indexeddb_description"
|
||||||
components={{
|
components={{
|
||||||
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues/208"/>,
|
githubLink: (
|
||||||
|
<Link href="https://github.com/binwiederhier/ntfy/issues/208" />
|
||||||
|
),
|
||||||
discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
|
discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
|
||||||
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
|
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
@ -100,25 +111,37 @@ class ErrorBoundaryImpl extends React.Component {
|
||||||
renderError() {
|
renderError() {
|
||||||
const { t } = this.props;
|
const { t } = this.props;
|
||||||
return (
|
return (
|
||||||
<div style={{margin: '20px'}}>
|
<div style={{ margin: "20px" }}>
|
||||||
<h2>{t("error_boundary_title")} 😮</h2>
|
<h2>{t("error_boundary_title")} 😮</h2>
|
||||||
<p>
|
<p>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="error_boundary_description"
|
i18nKey="error_boundary_description"
|
||||||
components={{
|
components={{
|
||||||
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues"/>,
|
githubLink: (
|
||||||
|
<Link href="https://github.com/binwiederhier/ntfy/issues" />
|
||||||
|
),
|
||||||
discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
|
discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
|
||||||
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
|
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<Button variant="outlined" onClick={() => this.copyStack()}>{t("error_boundary_button_copy_stack_trace")}</Button>
|
<Button variant="outlined" onClick={() => this.copyStack()}>
|
||||||
|
{t("error_boundary_button_copy_stack_trace")}
|
||||||
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
<h3>{t("error_boundary_stack_trace")}</h3>
|
<h3>{t("error_boundary_stack_trace")}</h3>
|
||||||
{this.state.niceStack
|
{this.state.niceStack ? (
|
||||||
? <pre>{this.state.niceStack}</pre>
|
<pre>{this.state.niceStack}</pre>
|
||||||
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> {t("error_boundary_gathering_info")}</>}
|
) : (
|
||||||
|
<>
|
||||||
|
<CircularProgress
|
||||||
|
size="20px"
|
||||||
|
sx={{ verticalAlign: "text-bottom" }}
|
||||||
|
/>{" "}
|
||||||
|
{t("error_boundary_gathering_info")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<pre>{this.state.originalStack}</pre>
|
<pre>{this.state.originalStack}</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import {useState} from 'react';
|
import { useState } from "react";
|
||||||
import Navigation from "./Navigation";
|
import Navigation from "./Navigation";
|
||||||
import Paper from "@mui/material/Paper";
|
import Paper from "@mui/material/Paper";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
@ -7,7 +7,7 @@ import TextField from "@mui/material/TextField";
|
||||||
import SendIcon from "@mui/icons-material/Send";
|
import SendIcon from "@mui/icons-material/Send";
|
||||||
import api from "../app/Api";
|
import api from "../app/Api";
|
||||||
import PublishDialog from "./PublishDialog";
|
import PublishDialog from "./PublishDialog";
|
||||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
|
||||||
import { Portal, Snackbar } from "@mui/material";
|
import { Portal, Snackbar } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
@ -24,17 +24,19 @@ const Messaging = (props) => {
|
||||||
|
|
||||||
const handleDialogClose = () => {
|
const handleDialogClose = () => {
|
||||||
props.onDialogOpenModeChange("");
|
props.onDialogOpenModeChange("");
|
||||||
setDialogKey(prev => prev+1);
|
setDialogKey((prev) => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{subscription && <MessageBar
|
{subscription && (
|
||||||
|
<MessageBar
|
||||||
subscription={subscription}
|
subscription={subscription}
|
||||||
message={message}
|
message={message}
|
||||||
onMessageChange={setMessage}
|
onMessageChange={setMessage}
|
||||||
onOpenDialogClick={handleOpenDialogClick}
|
onOpenDialogClick={handleOpenDialogClick}
|
||||||
/>}
|
/>
|
||||||
|
)}
|
||||||
<PublishDialog
|
<PublishDialog
|
||||||
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
|
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
|
||||||
openMode={dialogOpenMode}
|
openMode={dialogOpenMode}
|
||||||
|
@ -42,12 +44,18 @@ const Messaging = (props) => {
|
||||||
topic={subscription?.topic ?? ""}
|
topic={subscription?.topic ?? ""}
|
||||||
message={message}
|
message={message}
|
||||||
onClose={handleDialogClose}
|
onClose={handleDialogClose}
|
||||||
onDragEnter={() => props.onDialogOpenModeChange(prev => (prev) ? prev : PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open
|
onDragEnter={() =>
|
||||||
onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}
|
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 MessageBar = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -55,7 +63,11 @@ const MessageBar = (props) => {
|
||||||
const [snackOpen, setSnackOpen] = useState(false);
|
const [snackOpen, setSnackOpen] = useState(false);
|
||||||
const handleSendClick = async () => {
|
const handleSendClick = async () => {
|
||||||
try {
|
try {
|
||||||
await api.publish(subscription.baseUrl, subscription.topic, props.message);
|
await api.publish(
|
||||||
|
subscription.baseUrl,
|
||||||
|
subscription.topic,
|
||||||
|
props.message,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[MessageBar] Error publishing message`, e);
|
console.log(`[MessageBar] Error publishing message`, e);
|
||||||
setSnackOpen(true);
|
setSnackOpen(true);
|
||||||
|
@ -67,15 +79,24 @@ const MessageBar = (props) => {
|
||||||
elevation={3}
|
elevation={3}
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
position: 'fixed',
|
position: "fixed",
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
padding: 2,
|
padding: 2,
|
||||||
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
|
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
|
||||||
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
|
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")}>
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
size="large"
|
||||||
|
edge="start"
|
||||||
|
onClick={props.onOpenDialogClick}
|
||||||
|
aria-label={t("message_bar_show_dialog")}
|
||||||
|
>
|
||||||
<KeyboardArrowUpIcon />
|
<KeyboardArrowUpIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<TextField
|
<TextField
|
||||||
|
@ -88,15 +109,21 @@ const MessageBar = (props) => {
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="standard"
|
variant="standard"
|
||||||
value={props.message}
|
value={props.message}
|
||||||
onChange={ev => props.onMessageChange(ev.target.value)}
|
onChange={(ev) => props.onMessageChange(ev.target.value)}
|
||||||
onKeyPress={(ev) => {
|
onKeyPress={(ev) => {
|
||||||
if (ev.key === 'Enter') {
|
if (ev.key === "Enter") {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
handleSendClick();
|
handleSendClick();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick} aria-label={t("message_bar_publish")}>
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
size="large"
|
||||||
|
edge="end"
|
||||||
|
onClick={handleSendClick}
|
||||||
|
aria-label={t("message_bar_publish")}
|
||||||
|
>
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Portal>
|
<Portal>
|
||||||
|
|
|
@ -11,7 +11,14 @@ import List from "@mui/material/List";
|
||||||
import SettingsIcon from "@mui/icons-material/Settings";
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import SubscribeDialog from "./SubscribeDialog";
|
import SubscribeDialog from "./SubscribeDialog";
|
||||||
import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader} from "@mui/material";
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertTitle,
|
||||||
|
Badge,
|
||||||
|
CircularProgress,
|
||||||
|
Link,
|
||||||
|
ListSubheader,
|
||||||
|
} from "@mui/material";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
|
import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
|
||||||
|
@ -19,11 +26,15 @@ import routes from "./routes";
|
||||||
import { ConnectionState } from "../app/Connection";
|
import { ConnectionState } from "../app/Connection";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import {ChatBubble, NotificationsOffOutlined, Send} from "@mui/icons-material";
|
import {
|
||||||
|
ChatBubble,
|
||||||
|
NotificationsOffOutlined,
|
||||||
|
Send,
|
||||||
|
} from "@mui/icons-material";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import notifier from "../app/Notifier";
|
import notifier from "../app/Notifier";
|
||||||
import config from "../app/config";
|
import config from "../app/config";
|
||||||
import ArticleIcon from '@mui/icons-material/Article';
|
import ArticleIcon from "@mui/icons-material/Article";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const navWidth = 280;
|
const navWidth = 280;
|
||||||
|
@ -44,8 +55,8 @@ const Navigation = (props) => {
|
||||||
onClose={props.onMobileDrawerToggle}
|
onClose={props.onMobileDrawerToggle}
|
||||||
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
|
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
|
||||||
sx={{
|
sx={{
|
||||||
display: { xs: 'block', sm: 'none' },
|
display: { xs: "block", sm: "none" },
|
||||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
|
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{navigationList}
|
{navigationList}
|
||||||
|
@ -56,8 +67,8 @@ const Navigation = (props) => {
|
||||||
variant="permanent"
|
variant="permanent"
|
||||||
role="menubar"
|
role="menubar"
|
||||||
sx={{
|
sx={{
|
||||||
display: { xs: 'none', sm: 'block' },
|
display: { xs: "none", sm: "block" },
|
||||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
|
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{navigationList}
|
{navigationList}
|
||||||
|
@ -76,43 +87,76 @@ const NavList = (props) => {
|
||||||
|
|
||||||
const handleSubscribeReset = () => {
|
const handleSubscribeReset = () => {
|
||||||
setSubscribeDialogOpen(false);
|
setSubscribeDialogOpen(false);
|
||||||
setSubscribeDialogKey(prev => prev+1);
|
setSubscribeDialogKey((prev) => prev + 1);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSubscribeSubmit = (subscription) => {
|
const handleSubscribeSubmit = (subscription) => {
|
||||||
console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
|
console.log(
|
||||||
|
`[Navigation] New subscription: ${subscription.id}`,
|
||||||
|
subscription,
|
||||||
|
);
|
||||||
handleSubscribeReset();
|
handleSubscribeReset();
|
||||||
navigate(routes.forSubscription(subscription));
|
navigate(routes.forSubscription(subscription));
|
||||||
handleRequestNotificationPermission();
|
handleRequestNotificationPermission();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleRequestNotificationPermission = () => {
|
const handleRequestNotificationPermission = () => {
|
||||||
notifier.maybeRequestPermission(granted => props.onNotificationGranted(granted))
|
notifier.maybeRequestPermission((granted) =>
|
||||||
|
props.onNotificationGranted(granted),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showSubscriptionsList = props.subscriptions?.length > 0;
|
const showSubscriptionsList = props.subscriptions?.length > 0;
|
||||||
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
|
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
|
||||||
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
|
const showNotificationContextNotSupportedBox =
|
||||||
const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted;
|
notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
|
||||||
const navListPadding = (showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox) ? '0' : '';
|
const showNotificationGrantBox =
|
||||||
|
notifier.supported() &&
|
||||||
|
props.subscriptions?.length > 0 &&
|
||||||
|
!props.notificationsGranted;
|
||||||
|
const navListPadding =
|
||||||
|
showNotificationGrantBox ||
|
||||||
|
showNotificationBrowserNotSupportedBox ||
|
||||||
|
showNotificationContextNotSupportedBox
|
||||||
|
? "0"
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toolbar sx={{ display: { xs: 'none', sm: 'block' } }}/>
|
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
|
||||||
<List component="nav" sx={{ paddingTop: navListPadding }}>
|
<List component="nav" sx={{ paddingTop: navListPadding }}>
|
||||||
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert/>}
|
{showNotificationBrowserNotSupportedBox && (
|
||||||
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
|
<NotificationBrowserNotSupportedAlert />
|
||||||
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
|
)}
|
||||||
{!showSubscriptionsList &&
|
{showNotificationContextNotSupportedBox && (
|
||||||
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
|
<NotificationContextNotSupportedAlert />
|
||||||
<ListItemIcon><ChatBubble/></ListItemIcon>
|
)}
|
||||||
|
{showNotificationGrantBox && (
|
||||||
|
<NotificationGrantAlert
|
||||||
|
onRequestPermissionClick={handleRequestNotificationPermission}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!showSubscriptionsList && (
|
||||||
|
<ListItemButton
|
||||||
|
onClick={() => navigate(routes.root)}
|
||||||
|
selected={location.pathname === config.appRoot}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<ChatBubble />
|
||||||
|
</ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_all_notifications")} />
|
<ListItemText primary={t("nav_button_all_notifications")} />
|
||||||
</ListItemButton>}
|
</ListItemButton>
|
||||||
{showSubscriptionsList &&
|
)}
|
||||||
|
{showSubscriptionsList && (
|
||||||
<>
|
<>
|
||||||
<ListSubheader>{t("nav_topics_title")}</ListSubheader>
|
<ListSubheader>{t("nav_topics_title")}</ListSubheader>
|
||||||
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
|
<ListItemButton
|
||||||
<ListItemIcon><ChatBubble/></ListItemIcon>
|
onClick={() => navigate(routes.root)}
|
||||||
|
selected={location.pathname === config.appRoot}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<ChatBubble />
|
||||||
|
</ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_all_notifications")} />
|
<ListItemText primary={t("nav_button_all_notifications")} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
<SubscriptionList
|
<SubscriptionList
|
||||||
|
@ -120,21 +164,33 @@ const NavList = (props) => {
|
||||||
selectedSubscription={props.selectedSubscription}
|
selectedSubscription={props.selectedSubscription}
|
||||||
/>
|
/>
|
||||||
<Divider sx={{ my: 1 }} />
|
<Divider sx={{ my: 1 }} />
|
||||||
</>}
|
</>
|
||||||
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
|
)}
|
||||||
<ListItemIcon><SettingsIcon/></ListItemIcon>
|
<ListItemButton
|
||||||
|
onClick={() => navigate(routes.settings)}
|
||||||
|
selected={location.pathname === routes.settings}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<SettingsIcon />
|
||||||
|
</ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_settings")} />
|
<ListItemText primary={t("nav_button_settings")} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
<ListItemButton onClick={() => openUrl("/docs")}>
|
<ListItemButton onClick={() => openUrl("/docs")}>
|
||||||
<ListItemIcon><ArticleIcon/></ListItemIcon>
|
<ListItemIcon>
|
||||||
|
<ArticleIcon />
|
||||||
|
</ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_documentation")} />
|
<ListItemText primary={t("nav_button_documentation")} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
<ListItemButton onClick={() => props.onPublishMessageClick()}>
|
<ListItemButton onClick={() => props.onPublishMessageClick()}>
|
||||||
<ListItemIcon><Send/></ListItemIcon>
|
<ListItemIcon>
|
||||||
|
<Send />
|
||||||
|
</ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_publish_message")} />
|
<ListItemText primary={t("nav_button_publish_message")} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
|
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
|
||||||
<ListItemIcon><AddIcon/></ListItemIcon>
|
<ListItemIcon>
|
||||||
|
<AddIcon />
|
||||||
|
</ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_subscribe")} />
|
<ListItemText primary={t("nav_button_subscribe")} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</List>
|
</List>
|
||||||
|
@ -151,30 +207,44 @@ const NavList = (props) => {
|
||||||
|
|
||||||
const SubscriptionList = (props) => {
|
const SubscriptionList = (props) => {
|
||||||
const sortedSubscriptions = props.subscriptions.sort((a, b) => {
|
const sortedSubscriptions = props.subscriptions.sort((a, b) => {
|
||||||
return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1;
|
return topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1;
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sortedSubscriptions.map(subscription =>
|
{sortedSubscriptions.map((subscription) => (
|
||||||
<SubscriptionItem
|
<SubscriptionItem
|
||||||
key={subscription.id}
|
key={subscription.id}
|
||||||
subscription={subscription}
|
subscription={subscription}
|
||||||
selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id}
|
selected={
|
||||||
/>)}
|
props.selectedSubscription &&
|
||||||
|
props.selectedSubscription.id === subscription.id
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const SubscriptionItem = (props) => {
|
const SubscriptionItem = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const subscription = props.subscription;
|
const subscription = props.subscription;
|
||||||
const iconBadge = (subscription.new <= 99) ? subscription.new : "99+";
|
const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
|
||||||
const icon = (subscription.state === ConnectionState.Connecting)
|
const icon =
|
||||||
? <CircularProgress size="24px"/>
|
subscription.state === ConnectionState.Connecting ? (
|
||||||
: <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>;
|
<CircularProgress size="24px" />
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
badgeContent={iconBadge}
|
||||||
|
invisible={subscription.new === 0}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<ChatBubbleOutlineIcon />
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
const displayName = topicDisplayName(subscription);
|
const displayName = topicDisplayName(subscription);
|
||||||
const ariaLabel = (subscription.state === ConnectionState.Connecting)
|
const ariaLabel =
|
||||||
|
subscription.state === ConnectionState.Connecting
|
||||||
? `${displayName} (${t("nav_button_connecting")})`
|
? `${displayName} (${t("nav_button_connecting")})`
|
||||||
: displayName;
|
: displayName;
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
|
@ -182,11 +252,19 @@ const SubscriptionItem = (props) => {
|
||||||
await subscriptionManager.markNotificationsRead(subscription.id);
|
await subscriptionManager.markNotificationsRead(subscription.id);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
|
<ListItemButton
|
||||||
|
onClick={handleClick}
|
||||||
|
selected={props.selected}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
<ListItemIcon>{icon}</ListItemIcon>
|
<ListItemIcon>{icon}</ListItemIcon>
|
||||||
<ListItemText primary={displayName} />
|
<ListItemText primary={displayName} />
|
||||||
{subscription.mutedUntil > 0 &&
|
{subscription.mutedUntil > 0 && (
|
||||||
<ListItemIcon edge="end" aria-label={t("nav_button_muted")}><NotificationsOffOutlined /></ListItemIcon>}
|
<ListItemIcon edge="end" aria-label={t("nav_button_muted")}>
|
||||||
|
<NotificationsOffOutlined />
|
||||||
|
</ListItemIcon>
|
||||||
|
)}
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -199,7 +277,7 @@ const NotificationGrantAlert = (props) => {
|
||||||
<AlertTitle>{t("alert_grant_title")}</AlertTitle>
|
<AlertTitle>{t("alert_grant_title")}</AlertTitle>
|
||||||
<Typography gutterBottom>{t("alert_grant_description")}</Typography>
|
<Typography gutterBottom>{t("alert_grant_description")}</Typography>
|
||||||
<Button
|
<Button
|
||||||
sx={{float: 'right'}}
|
sx={{ float: "right" }}
|
||||||
color="inherit"
|
color="inherit"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={props.onRequestPermissionClick}
|
onClick={props.onRequestPermissionClick}
|
||||||
|
@ -218,7 +296,9 @@ const NotificationBrowserNotSupportedAlert = () => {
|
||||||
<>
|
<>
|
||||||
<Alert severity="warning" sx={{ paddingTop: 2 }}>
|
<Alert severity="warning" sx={{ paddingTop: 2 }}>
|
||||||
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
|
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
|
||||||
<Typography gutterBottom>{t("alert_not_supported_description")}</Typography>
|
<Typography gutterBottom>
|
||||||
|
{t("alert_not_supported_description")}
|
||||||
|
</Typography>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Divider />
|
<Divider />
|
||||||
</>
|
</>
|
||||||
|
@ -235,7 +315,13 @@ const NotificationContextNotSupportedAlert = () => {
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="alert_not_supported_context_description"
|
i18nKey="alert_not_supported_context_description"
|
||||||
components={{
|
components={{
|
||||||
mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener"/>
|
mdnLink: (
|
||||||
|
<Link
|
||||||
|
href="https://developer.mozilla.org/en-US/docs/Web/API/notification"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
/>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
Modal,
|
Modal,
|
||||||
Snackbar,
|
Snackbar,
|
||||||
Stack,
|
Stack,
|
||||||
Tooltip
|
Tooltip,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import Card from "@mui/material/Card";
|
import Card from "@mui/material/Card";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
|
@ -19,16 +19,21 @@ import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatMessage,
|
formatMessage,
|
||||||
formatShortDateTime,
|
formatShortDateTime,
|
||||||
formatTitle, maybeAppendActionErrors,
|
formatTitle,
|
||||||
|
maybeAppendActionErrors,
|
||||||
openUrl,
|
openUrl,
|
||||||
shortUrl,
|
shortUrl,
|
||||||
topicShortUrl,
|
topicShortUrl,
|
||||||
unmatchedTags
|
unmatchedTags,
|
||||||
} from "../app/utils";
|
} from "../app/utils";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
import CheckIcon from "@mui/icons-material/Check";
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles";
|
import {
|
||||||
|
LightboxBackdrop,
|
||||||
|
Paragraph,
|
||||||
|
VerticallyCenteredContainer,
|
||||||
|
} from "./styles";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
|
@ -44,14 +49,25 @@ import {Trans, useTranslation} from "react-i18next";
|
||||||
|
|
||||||
const Notifications = (props) => {
|
const Notifications = (props) => {
|
||||||
if (props.mode === "all") {
|
if (props.mode === "all") {
|
||||||
return (props.subscriptions) ? <AllSubscriptions subscriptions={props.subscriptions}/> : <Loading/>;
|
return props.subscriptions ? (
|
||||||
}
|
<AllSubscriptions subscriptions={props.subscriptions} />
|
||||||
return (props.subscription) ? <SingleSubscription subscription={props.subscription}/> : <Loading/>;
|
) : (
|
||||||
|
<Loading />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return props.subscription ? (
|
||||||
|
<SingleSubscription subscription={props.subscription} />
|
||||||
|
) : (
|
||||||
|
<Loading />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const AllSubscriptions = (props) => {
|
const AllSubscriptions = (props) => {
|
||||||
const subscriptions = props.subscriptions;
|
const subscriptions = props.subscriptions;
|
||||||
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
|
const notifications = useLiveQuery(
|
||||||
|
() => subscriptionManager.getAllNotifications(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
if (notifications === null || notifications === undefined) {
|
if (notifications === null || notifications === undefined) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
} else if (subscriptions.length === 0) {
|
} else if (subscriptions.length === 0) {
|
||||||
|
@ -59,19 +75,34 @@ const AllSubscriptions = (props) => {
|
||||||
} else if (notifications.length === 0) {
|
} else if (notifications.length === 0) {
|
||||||
return <NoNotificationsWithoutSubscription subscriptions={subscriptions} />;
|
return <NoNotificationsWithoutSubscription subscriptions={subscriptions} />;
|
||||||
}
|
}
|
||||||
return <NotificationList key="all" notifications={notifications} messageBar={false}/>;
|
return (
|
||||||
}
|
<NotificationList
|
||||||
|
key="all"
|
||||||
|
notifications={notifications}
|
||||||
|
messageBar={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const SingleSubscription = (props) => {
|
const SingleSubscription = (props) => {
|
||||||
const subscription = props.subscription;
|
const subscription = props.subscription;
|
||||||
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
|
const notifications = useLiveQuery(
|
||||||
|
() => subscriptionManager.getNotifications(subscription.id),
|
||||||
|
[subscription],
|
||||||
|
);
|
||||||
if (notifications === null || notifications === undefined) {
|
if (notifications === null || notifications === undefined) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
} else if (notifications.length === 0) {
|
} else if (notifications.length === 0) {
|
||||||
return <NoNotifications subscription={subscription} />;
|
return <NoNotifications subscription={subscription} />;
|
||||||
}
|
}
|
||||||
return <NotificationList id={subscription.id} notifications={notifications} messageBar={true}/>;
|
return (
|
||||||
}
|
<NotificationList
|
||||||
|
id={subscription.id}
|
||||||
|
notifications={notifications}
|
||||||
|
messageBar={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const NotificationList = (props) => {
|
const NotificationList = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -85,13 +116,13 @@ const NotificationList = (props) => {
|
||||||
return () => {
|
return () => {
|
||||||
setMaxCount(pageSize);
|
setMaxCount(pageSize);
|
||||||
document.getElementById("main").scrollTo(0, 0);
|
document.getElementById("main").scrollTo(0, 0);
|
||||||
}
|
};
|
||||||
}, [props.id]);
|
}, [props.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
dataLength={count}
|
dataLength={count}
|
||||||
next={() => setMaxCount(prev => prev + pageSize)}
|
next={() => setMaxCount((prev) => prev + pageSize)}
|
||||||
hasMore={count < notifications.length}
|
hasMore={count < notifications.length}
|
||||||
loader={<>Loading ...</>}
|
loader={<>Loading ...</>}
|
||||||
scrollThreshold={0.7}
|
scrollThreshold={0.7}
|
||||||
|
@ -103,16 +134,17 @@ const NotificationList = (props) => {
|
||||||
aria-label={t("notifications_list")}
|
aria-label={t("notifications_list")}
|
||||||
sx={{
|
sx={{
|
||||||
marginTop: 3,
|
marginTop: 3,
|
||||||
marginBottom: (props.messageBar) ? "100px" : 3 // Hack to avoid hiding notifications behind the message bar
|
marginBottom: props.messageBar ? "100px" : 3, // Hack to avoid hiding notifications behind the message bar
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
{notifications.slice(0, count).map(notification =>
|
{notifications.slice(0, count).map((notification) => (
|
||||||
<NotificationItem
|
<NotificationItem
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
notification={notification}
|
notification={notification}
|
||||||
onShowSnack={() => setSnackOpen(true)}
|
onShowSnack={() => setSnackOpen(true)}
|
||||||
/>)}
|
/>
|
||||||
|
))}
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={snackOpen}
|
open={snackOpen}
|
||||||
autoHideDuration={3000}
|
autoHideDuration={3000}
|
||||||
|
@ -123,7 +155,7 @@ const NotificationList = (props) => {
|
||||||
</Container>
|
</Container>
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const NotificationItem = (props) => {
|
const NotificationItem = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -131,81 +163,138 @@ const NotificationItem = (props) => {
|
||||||
const attachment = notification.attachment;
|
const attachment = notification.attachment;
|
||||||
const date = formatShortDateTime(notification.time);
|
const date = formatShortDateTime(notification.time);
|
||||||
const otherTags = unmatchedTags(notification.tags);
|
const otherTags = unmatchedTags(notification.tags);
|
||||||
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
|
const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
console.log(`[Notifications] Deleting notification ${notification.id}`);
|
console.log(`[Notifications] Deleting notification ${notification.id}`);
|
||||||
await subscriptionManager.deleteNotification(notification.id)
|
await subscriptionManager.deleteNotification(notification.id);
|
||||||
}
|
};
|
||||||
const handleMarkRead = async () => {
|
const handleMarkRead = async () => {
|
||||||
console.log(`[Notifications] Marking notification ${notification.id} as read`);
|
console.log(
|
||||||
await subscriptionManager.markNotificationRead(notification.id)
|
`[Notifications] Marking notification ${notification.id} as read`,
|
||||||
}
|
);
|
||||||
|
await subscriptionManager.markNotificationRead(notification.id);
|
||||||
|
};
|
||||||
const handleCopy = (s) => {
|
const handleCopy = (s) => {
|
||||||
navigator.clipboard.writeText(s);
|
navigator.clipboard.writeText(s);
|
||||||
props.onShowSnack();
|
props.onShowSnack();
|
||||||
};
|
};
|
||||||
const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000;
|
const expired =
|
||||||
|
attachment && attachment.expires && attachment.expires < Date.now() / 1000;
|
||||||
const hasAttachmentActions = attachment && !expired;
|
const hasAttachmentActions = attachment && !expired;
|
||||||
const hasClickAction = notification.click;
|
const hasClickAction = notification.click;
|
||||||
const hasUserActions = notification.actions && notification.actions.length > 0;
|
const hasUserActions =
|
||||||
|
notification.actions && notification.actions.length > 0;
|
||||||
const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
|
const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
|
||||||
return (
|
return (
|
||||||
<Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
|
<Card
|
||||||
|
sx={{ minWidth: 275, padding: 1 }}
|
||||||
|
role="listitem"
|
||||||
|
aria-label={t("notifications_list_item")}
|
||||||
|
>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tooltip title={t("notifications_delete")} enterDelay={500}>
|
<Tooltip title={t("notifications_delete")} enterDelay={500}>
|
||||||
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}>
|
<IconButton
|
||||||
|
onClick={handleDelete}
|
||||||
|
sx={{ float: "right", marginRight: -1, marginTop: -1 }}
|
||||||
|
aria-label={t("notifications_delete")}
|
||||||
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{notification.new === 1 &&
|
{notification.new === 1 && (
|
||||||
<Tooltip title={t("notifications_mark_read")} enterDelay={500}>
|
<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")}>
|
<IconButton
|
||||||
|
onClick={handleMarkRead}
|
||||||
|
sx={{ float: "right", marginRight: -0.5, marginTop: -1 }}
|
||||||
|
aria-label={t("notifications_mark_read")}
|
||||||
|
>
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>}
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Typography sx={{ fontSize: 14 }} color="text.secondary">
|
<Typography sx={{ fontSize: 14 }} color="text.secondary">
|
||||||
{date}
|
{date}
|
||||||
{[1,2,4,5].includes(notification.priority) &&
|
{[1, 2, 4, 5].includes(notification.priority) && (
|
||||||
<img
|
<img
|
||||||
src={priorityFiles[notification.priority]}
|
src={priorityFiles[notification.priority]}
|
||||||
alt={t("notifications_priority_x", { priority: notification.priority})}
|
alt={t("notifications_priority_x", {
|
||||||
style={{ verticalAlign: 'bottom' }}
|
priority: notification.priority,
|
||||||
/>}
|
})}
|
||||||
{notification.new === 1 &&
|
style={{ verticalAlign: "bottom" }}
|
||||||
<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")}>
|
/>
|
||||||
|
)}
|
||||||
|
{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" />
|
<circle cx="50" cy="50" r="50" fill="#338574" />
|
||||||
</svg>}
|
</svg>
|
||||||
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{notification.title && <Typography variant="h5" component="div" role="rowheader">{formatTitle(notification)}</Typography>}
|
{notification.title && (
|
||||||
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>
|
<Typography variant="h5" component="div" role="rowheader">
|
||||||
{autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
|
{formatTitle(notification)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
|
||||||
|
{autolink(
|
||||||
|
maybeAppendActionErrors(formatMessage(notification), notification),
|
||||||
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{attachment && <Attachment attachment={attachment} />}
|
{attachment && <Attachment attachment={attachment} />}
|
||||||
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">{t("notifications_tags")}: {tags}</Typography>}
|
{tags && (
|
||||||
|
<Typography sx={{ fontSize: 14 }} color="text.secondary">
|
||||||
|
{t("notifications_tags")}: {tags}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{showActions &&
|
{showActions && (
|
||||||
<CardActions sx={{ paddingTop: 0 }}>
|
<CardActions sx={{ paddingTop: 0 }}>
|
||||||
{hasAttachmentActions && <>
|
{hasAttachmentActions && (
|
||||||
|
<>
|
||||||
<Tooltip title={t("notifications_attachment_copy_url_title")}>
|
<Tooltip title={t("notifications_attachment_copy_url_title")}>
|
||||||
<Button onClick={() => handleCopy(attachment.url)}>{t("notifications_attachment_copy_url_button")}</Button>
|
<Button onClick={() => handleCopy(attachment.url)}>
|
||||||
|
{t("notifications_attachment_copy_url_button")}
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t("notifications_attachment_open_title", { url: attachment.url })}>
|
<Tooltip
|
||||||
<Button onClick={() => openUrl(attachment.url)}>{t("notifications_attachment_open_button")}</Button>
|
title={t("notifications_attachment_open_title", {
|
||||||
|
url: attachment.url,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button onClick={() => openUrl(attachment.url)}>
|
||||||
|
{t("notifications_attachment_open_button")}
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>}
|
</>
|
||||||
{hasClickAction && <>
|
)}
|
||||||
|
{hasClickAction && (
|
||||||
|
<>
|
||||||
<Tooltip title={t("notifications_click_copy_url_title")}>
|
<Tooltip title={t("notifications_click_copy_url_title")}>
|
||||||
<Button onClick={() => handleCopy(notification.click)}>{t("notifications_click_copy_url_button")}</Button>
|
<Button onClick={() => handleCopy(notification.click)}>
|
||||||
|
{t("notifications_click_copy_url_button")}
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t("notifications_actions_open_url_title", { url: notification.click })}>
|
<Tooltip
|
||||||
<Button onClick={() => openUrl(notification.click)}>{t("notifications_click_open_button")}</Button>
|
title={t("notifications_actions_open_url_title", {
|
||||||
|
url: notification.click,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button onClick={() => openUrl(notification.click)}>
|
||||||
|
{t("notifications_click_open_button")}
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>}
|
</>
|
||||||
|
)}
|
||||||
{hasUserActions && <UserActions notification={notification} />}
|
{hasUserActions && <UserActions notification={notification} />}
|
||||||
</CardActions>}
|
</CardActions>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace links with <Link/> components; this is a combination of the genius function
|
* Replace links with <Link/> components; this is a combination of the genius function
|
||||||
|
@ -215,9 +304,21 @@ const NotificationItem = (props) => {
|
||||||
* [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9
|
* [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9
|
||||||
*/
|
*/
|
||||||
const autolink = (s) => {
|
const autolink = (s) => {
|
||||||
const parts = s.split(/(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi);
|
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) {
|
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>;
|
parts[i] = (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
href={parts[i]}
|
||||||
|
underline="hover"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer,noopener"
|
||||||
|
>
|
||||||
|
{shortUrl(parts[i])}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return <>{parts}</>;
|
return <>{parts}</>;
|
||||||
};
|
};
|
||||||
|
@ -226,7 +327,7 @@ const priorityFiles = {
|
||||||
1: priority1,
|
1: priority1,
|
||||||
2: priority2,
|
2: priority2,
|
||||||
4: priority4,
|
4: priority4,
|
||||||
5: priority5
|
5: priority5,
|
||||||
};
|
};
|
||||||
|
|
||||||
const Attachment = (props) => {
|
const Attachment = (props) => {
|
||||||
|
@ -234,7 +335,8 @@ const Attachment = (props) => {
|
||||||
const attachment = props.attachment;
|
const attachment = props.attachment;
|
||||||
const expired = attachment.expires && attachment.expires < Date.now() / 1000;
|
const expired = attachment.expires && attachment.expires < Date.now() / 1000;
|
||||||
const expires = attachment.expires && attachment.expires > Date.now() / 1000;
|
const expires = attachment.expires && attachment.expires > Date.now() / 1000;
|
||||||
const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
|
const displayableImage =
|
||||||
|
!expired && attachment.type && attachment.type.startsWith("image/");
|
||||||
|
|
||||||
// Unexpired image
|
// Unexpired image
|
||||||
if (displayableImage) {
|
if (displayableImage) {
|
||||||
|
@ -247,25 +349,40 @@ const Attachment = (props) => {
|
||||||
infos.push(formatBytes(attachment.size));
|
infos.push(formatBytes(attachment.size));
|
||||||
}
|
}
|
||||||
if (expires) {
|
if (expires) {
|
||||||
infos.push(t("notifications_attachment_link_expires", { date: formatShortDateTime(attachment.expires) }));
|
infos.push(
|
||||||
|
t("notifications_attachment_link_expires", {
|
||||||
|
date: formatShortDateTime(attachment.expires),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (expired) {
|
if (expired) {
|
||||||
infos.push(t("notifications_attachment_link_expired"));
|
infos.push(t("notifications_attachment_link_expired"));
|
||||||
}
|
}
|
||||||
const maybeInfoText = (infos.length > 0) ? <><br/>{infos.join(", ")}</> : null;
|
const maybeInfoText =
|
||||||
|
infos.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
{infos.join(", ")}
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
// If expired, just show infos without click target
|
// If expired, just show infos without click target
|
||||||
if (expired) {
|
if (expired) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{
|
<Box
|
||||||
display: 'flex',
|
sx={{
|
||||||
alignItems: 'center',
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
padding: 1,
|
padding: 1,
|
||||||
borderRadius: '4px',
|
borderRadius: "4px",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<AttachmentIcon type={attachment.type} />
|
<AttachmentIcon type={attachment.type} />
|
||||||
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}
|
||||||
|
>
|
||||||
<b>{attachment.name}</b>
|
<b>{attachment.name}</b>
|
||||||
{maybeInfoText}
|
{maybeInfoText}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
@ -275,26 +392,31 @@ const Attachment = (props) => {
|
||||||
|
|
||||||
// Not expired
|
// Not expired
|
||||||
return (
|
return (
|
||||||
<ButtonBase sx={{
|
<ButtonBase
|
||||||
|
sx={{
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
href={attachment.url}
|
href={attachment.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
underline="none"
|
underline="none"
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
padding: 1,
|
padding: 1,
|
||||||
borderRadius: '4px',
|
borderRadius: "4px",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.05)'
|
backgroundColor: "rgba(0, 0, 0, 0.05)",
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AttachmentIcon type={attachment.type} />
|
<AttachmentIcon type={attachment.type} />
|
||||||
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}
|
||||||
|
>
|
||||||
<b>{attachment.name}</b>
|
<b>{attachment.name}</b>
|
||||||
{maybeInfoText}
|
{maybeInfoText}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
@ -316,12 +438,12 @@ const Image = (props) => {
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
sx={{
|
sx={{
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
borderRadius: '4px',
|
borderRadius: "4px",
|
||||||
boxShadow: 2,
|
boxShadow: 2,
|
||||||
width: 1,
|
width: 1,
|
||||||
maxHeight: '400px',
|
maxHeight: "400px",
|
||||||
objectFit: 'cover',
|
objectFit: "cover",
|
||||||
cursor: 'pointer'
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
|
@ -338,10 +460,10 @@ const Image = (props) => {
|
||||||
sx={{
|
sx={{
|
||||||
maxWidth: 1,
|
maxWidth: 1,
|
||||||
maxHeight: 1,
|
maxHeight: 1,
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: '50%',
|
top: "50%",
|
||||||
left: '50%',
|
left: "50%",
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: "translate(-50%, -50%)",
|
||||||
padding: 4,
|
padding: 4,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -349,12 +471,19 @@ const Image = (props) => {
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const UserActions = (props) => {
|
const UserActions = (props) => {
|
||||||
return (
|
return (
|
||||||
<>{props.notification.actions.map(action =>
|
<>
|
||||||
<UserAction key={action.id} notification={props.notification} action={action}/>)}</>
|
{props.notification.actions.map((action) => (
|
||||||
|
<UserAction
|
||||||
|
key={action.id}
|
||||||
|
notification={props.notification}
|
||||||
|
action={action}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -365,27 +494,51 @@ const UserAction = (props) => {
|
||||||
if (action.action === "broadcast") {
|
if (action.action === "broadcast") {
|
||||||
return (
|
return (
|
||||||
<Tooltip title={t("notifications_actions_not_supported")}>
|
<Tooltip title={t("notifications_actions_not_supported")}>
|
||||||
<span><Button disabled aria-label={t("notifications_actions_not_supported")}>{action.label}</Button></span>
|
<span>
|
||||||
|
<Button
|
||||||
|
disabled
|
||||||
|
aria-label={t("notifications_actions_not_supported")}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
} else if (action.action === "view") {
|
} else if (action.action === "view") {
|
||||||
return (
|
return (
|
||||||
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
|
<Tooltip
|
||||||
|
title={t("notifications_actions_open_url_title", { url: action.url })}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => openUrl(action.url)}
|
onClick={() => openUrl(action.url)}
|
||||||
aria-label={t("notifications_actions_open_url_title", { url: action.url })}
|
aria-label={t("notifications_actions_open_url_title", {
|
||||||
>{action.label}</Button>
|
url: action.url,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
} else if (action.action === "http") {
|
} else if (action.action === "http") {
|
||||||
const method = action.method ?? "POST";
|
const method = action.method ?? "POST";
|
||||||
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
|
const label =
|
||||||
|
action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
|
||||||
return (
|
return (
|
||||||
<Tooltip title={t("notifications_actions_http_request_title", { method: method, url: action.url })}>
|
<Tooltip
|
||||||
|
title={t("notifications_actions_http_request_title", {
|
||||||
|
method: method,
|
||||||
|
url: action.url,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => performHttpAction(notification, action)}
|
onClick={() => performHttpAction(notification, action)}
|
||||||
aria-label={t("notifications_actions_http_request_title", { method: method, url: action.url })}
|
aria-label={t("notifications_actions_http_request_title", {
|
||||||
>{label}</Button>
|
method: method,
|
||||||
|
url: action.url,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -401,30 +554,40 @@ const performHttpAction = async (notification, action) => {
|
||||||
headers: action.headers ?? {},
|
headers: action.headers ?? {},
|
||||||
// This must not null-coalesce to a non nullish value. Otherwise, the fetch API
|
// This must not null-coalesce to a non nullish value. Otherwise, the fetch API
|
||||||
// will reject it for "having a body"
|
// will reject it for "having a body"
|
||||||
body: action.body
|
body: action.body,
|
||||||
});
|
});
|
||||||
console.log(`[Notifications] HTTP user action response`, response);
|
console.log(`[Notifications] HTTP user action response`, response);
|
||||||
const success = response.status >= 200 && response.status <= 299;
|
const success = response.status >= 200 && response.status <= 299;
|
||||||
if (success) {
|
if (success) {
|
||||||
updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null);
|
updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null);
|
||||||
} else {
|
} else {
|
||||||
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`);
|
updateActionStatus(
|
||||||
|
notification,
|
||||||
|
action,
|
||||||
|
ACTION_PROGRESS_FAILED,
|
||||||
|
`${action.label}: Unexpected response HTTP ${response.status}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[Notifications] HTTP action failed`, e);
|
console.log(`[Notifications] HTTP action failed`, e);
|
||||||
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`);
|
updateActionStatus(
|
||||||
|
notification,
|
||||||
|
action,
|
||||||
|
ACTION_PROGRESS_FAILED,
|
||||||
|
`${action.label}: ${e} Check developer console for details.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateActionStatus = (notification, action, progress, error) => {
|
const updateActionStatus = (notification, action, progress, error) => {
|
||||||
notification.actions = notification.actions.map(a => {
|
notification.actions = notification.actions.map((a) => {
|
||||||
if (a.id !== action.id) {
|
if (a.id !== action.id) {
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
return { ...a, progress: progress, error: error };
|
return { ...a, progress: progress, error: error };
|
||||||
});
|
});
|
||||||
subscriptionManager.updateNotification(notification);
|
subscriptionManager.updateNotification(notification);
|
||||||
}
|
};
|
||||||
|
|
||||||
const ACTION_PROGRESS_ONGOING = 1;
|
const ACTION_PROGRESS_ONGOING = 1;
|
||||||
const ACTION_PROGRESS_SUCCESS = 2;
|
const ACTION_PROGRESS_SUCCESS = 2;
|
||||||
|
@ -433,26 +596,31 @@ const ACTION_PROGRESS_FAILED = 3;
|
||||||
const ACTION_LABEL_SUFFIX = {
|
const ACTION_LABEL_SUFFIX = {
|
||||||
[ACTION_PROGRESS_ONGOING]: " …",
|
[ACTION_PROGRESS_ONGOING]: " …",
|
||||||
[ACTION_PROGRESS_SUCCESS]: " ✔",
|
[ACTION_PROGRESS_SUCCESS]: " ✔",
|
||||||
[ACTION_PROGRESS_FAILED]: " ❌"
|
[ACTION_PROGRESS_FAILED]: " ❌",
|
||||||
};
|
};
|
||||||
|
|
||||||
const NoNotifications = (props) => {
|
const NoNotifications = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
|
const shortUrl = topicShortUrl(
|
||||||
|
props.subscription.baseUrl,
|
||||||
|
props.subscription.topic,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<VerticallyCenteredContainer maxWidth="xs">
|
<VerticallyCenteredContainer maxWidth="xs">
|
||||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||||
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
|
<img
|
||||||
|
src={logoOutline}
|
||||||
|
height="64"
|
||||||
|
width="64"
|
||||||
|
alt={t("action_bar_logo_alt")}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
{t("notifications_none_for_topic_title")}
|
{t("notifications_none_for_topic_title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Paragraph>
|
<Paragraph>{t("notifications_none_for_topic_description")}</Paragraph>
|
||||||
{t("notifications_none_for_topic_description")}
|
|
||||||
</Paragraph>
|
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
{t("notifications_example")}:<br />
|
{t("notifications_example")}:<br />
|
||||||
<tt>
|
<tt>$ curl -d "Hi" {shortUrl}</tt>
|
||||||
$ curl -d "Hi" {shortUrl}
|
|
||||||
</tt>
|
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
<ForMoreDetails />
|
<ForMoreDetails />
|
||||||
|
@ -468,17 +636,19 @@ const NoNotificationsWithoutSubscription = (props) => {
|
||||||
return (
|
return (
|
||||||
<VerticallyCenteredContainer maxWidth="xs">
|
<VerticallyCenteredContainer maxWidth="xs">
|
||||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||||
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
|
<img
|
||||||
|
src={logoOutline}
|
||||||
|
height="64"
|
||||||
|
width="64"
|
||||||
|
alt={t("action_bar_logo_alt")}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
{t("notifications_none_for_any_title")}
|
{t("notifications_none_for_any_title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Paragraph>
|
<Paragraph>{t("notifications_none_for_any_description")}</Paragraph>
|
||||||
{t("notifications_none_for_any_description")}
|
|
||||||
</Paragraph>
|
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
{t("notifications_example")}:<br />
|
{t("notifications_example")}:<br />
|
||||||
<tt>
|
<tt>$ curl -d "Hi" {shortUrl}</tt>
|
||||||
$ curl -d "Hi" {shortUrl}
|
|
||||||
</tt>
|
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
<ForMoreDetails />
|
<ForMoreDetails />
|
||||||
|
@ -492,12 +662,18 @@ const NoSubscriptions = () => {
|
||||||
return (
|
return (
|
||||||
<VerticallyCenteredContainer maxWidth="xs">
|
<VerticallyCenteredContainer maxWidth="xs">
|
||||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||||
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
|
<img
|
||||||
|
src={logoOutline}
|
||||||
|
height="64"
|
||||||
|
width="64"
|
||||||
|
alt={t("action_bar_logo_alt")}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
{t("notifications_no_subscriptions_title")}
|
{t("notifications_no_subscriptions_title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
{t("notifications_no_subscriptions_description", {
|
{t("notifications_no_subscriptions_description", {
|
||||||
linktext: t("nav_button_subscribe")
|
linktext: t("nav_button_subscribe"),
|
||||||
})}
|
})}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
|
@ -512,8 +688,12 @@ const ForMoreDetails = () => {
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="notifications_more_details"
|
i18nKey="notifications_more_details"
|
||||||
components={{
|
components={{
|
||||||
websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener"/>,
|
websiteLink: (
|
||||||
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/>
|
<Link href="https://ntfy.sh" target="_blank" rel="noopener" />
|
||||||
|
),
|
||||||
|
docsLink: (
|
||||||
|
<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -523,8 +703,14 @@ const Loading = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<VerticallyCenteredContainer>
|
<VerticallyCenteredContainer>
|
||||||
<Typography variant="h5" color="text.secondary" align="center" sx={{ paddingBottom: 1 }}>
|
<Typography
|
||||||
<CircularProgress disableShrink sx={{marginBottom: 1}}/><br />
|
variant="h5"
|
||||||
|
color="text.secondary"
|
||||||
|
align="center"
|
||||||
|
sx={{ paddingBottom: 1 }}
|
||||||
|
>
|
||||||
|
<CircularProgress disableShrink sx={{ marginBottom: 1 }} />
|
||||||
|
<br />
|
||||||
{t("notifications_loading")}
|
{t("notifications_loading")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</VerticallyCenteredContainer>
|
</VerticallyCenteredContainer>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import {useEffect, useState} from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
CardActions,
|
CardActions,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
@ -11,15 +11,15 @@ import {
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
useMediaQuery
|
useMediaQuery,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import prefs from "../app/Prefs";
|
import prefs from "../app/Prefs";
|
||||||
import { Paragraph } from "./styles";
|
import { Paragraph } from "./styles";
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
import Container from "@mui/material/Container";
|
import Container from "@mui/material/Container";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
@ -69,7 +69,7 @@ const Sound = () => {
|
||||||
const sound = useLiveQuery(async () => prefs.sound());
|
const sound = useLiveQuery(async () => prefs.sound());
|
||||||
const handleChange = async (ev) => {
|
const handleChange = async (ev) => {
|
||||||
await prefs.setSound(ev.target.value);
|
await prefs.setSound(ev.target.value);
|
||||||
}
|
};
|
||||||
if (!sound) {
|
if (!sound) {
|
||||||
return null; // While loading
|
return null; // While loading
|
||||||
}
|
}
|
||||||
|
@ -77,23 +77,43 @@ const Sound = () => {
|
||||||
if (sound === "none") {
|
if (sound === "none") {
|
||||||
description = t("prefs_notifications_sound_description_none");
|
description = t("prefs_notifications_sound_description_none");
|
||||||
} else {
|
} else {
|
||||||
description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label });
|
description = t("prefs_notifications_sound_description_some", {
|
||||||
|
sound: sounds[sound].label,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Pref labelId={labelId} title={t("prefs_notifications_sound_title")} description={description}>
|
<Pref
|
||||||
<div style={{ display: 'flex', width: '100%' }}>
|
labelId={labelId}
|
||||||
|
title={t("prefs_notifications_sound_title")}
|
||||||
|
description={description}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", width: "100%" }}>
|
||||||
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
|
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
|
||||||
<Select value={sound} onChange={handleChange} aria-labelledby={labelId}>
|
<Select
|
||||||
<MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem>
|
value={sound}
|
||||||
{Object.entries(sounds).map(s => <MenuItem key={s[0]} value={s[0]}>{s[1].label}</MenuItem>)}
|
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>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<IconButton onClick={() => playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}>
|
<IconButton
|
||||||
|
onClick={() => playSound(sound)}
|
||||||
|
disabled={sound === "none"}
|
||||||
|
aria-label={t("prefs_notifications_sound_play")}
|
||||||
|
>
|
||||||
<PlayArrowIcon />
|
<PlayArrowIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</Pref>
|
</Pref>
|
||||||
)
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MinPriority = () => {
|
const MinPriority = () => {
|
||||||
|
@ -102,7 +122,7 @@ const MinPriority = () => {
|
||||||
const minPriority = useLiveQuery(async () => prefs.minPriority());
|
const minPriority = useLiveQuery(async () => prefs.minPriority());
|
||||||
const handleChange = async (ev) => {
|
const handleChange = async (ev) => {
|
||||||
await prefs.setMinPriority(ev.target.value);
|
await prefs.setMinPriority(ev.target.value);
|
||||||
}
|
};
|
||||||
if (!minPriority) {
|
if (!minPriority) {
|
||||||
return null; // While loading
|
return null; // While loading
|
||||||
}
|
}
|
||||||
|
@ -111,32 +131,53 @@ const MinPriority = () => {
|
||||||
2: t("priority_low"),
|
2: t("priority_low"),
|
||||||
3: t("priority_default"),
|
3: t("priority_default"),
|
||||||
4: t("priority_high"),
|
4: t("priority_high"),
|
||||||
5: t("priority_max")
|
5: t("priority_max"),
|
||||||
}
|
};
|
||||||
let description;
|
let description;
|
||||||
if (minPriority === 1) {
|
if (minPriority === 1) {
|
||||||
description = t("prefs_notifications_min_priority_description_any");
|
description = t("prefs_notifications_min_priority_description_any");
|
||||||
} else if (minPriority === 5) {
|
} else if (minPriority === 5) {
|
||||||
description = t("prefs_notifications_min_priority_description_max");
|
description = t("prefs_notifications_min_priority_description_max");
|
||||||
} else {
|
} else {
|
||||||
description = t("prefs_notifications_min_priority_description_x_or_higher", {
|
description = t(
|
||||||
|
"prefs_notifications_min_priority_description_x_or_higher",
|
||||||
|
{
|
||||||
number: minPriority,
|
number: minPriority,
|
||||||
name: priorities[minPriority]
|
name: priorities[minPriority],
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Pref labelId={labelId} title={t("prefs_notifications_min_priority_title")} description={description}>
|
<Pref
|
||||||
|
labelId={labelId}
|
||||||
|
title={t("prefs_notifications_min_priority_title")}
|
||||||
|
description={description}
|
||||||
|
>
|
||||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||||
<Select value={minPriority} onChange={handleChange} aria-labelledby={labelId}>
|
<Select
|
||||||
<MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem>
|
value={minPriority}
|
||||||
<MenuItem value={2}>{t("prefs_notifications_min_priority_low_and_higher")}</MenuItem>
|
onChange={handleChange}
|
||||||
<MenuItem value={3}>{t("prefs_notifications_min_priority_default_and_higher")}</MenuItem>
|
aria-labelledby={labelId}
|
||||||
<MenuItem value={4}>{t("prefs_notifications_min_priority_high_and_higher")}</MenuItem>
|
>
|
||||||
<MenuItem value={5}>{t("prefs_notifications_min_priority_max_only")}</MenuItem>
|
<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>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Pref>
|
</Pref>
|
||||||
)
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DeleteAfter = () => {
|
const DeleteAfter = () => {
|
||||||
|
@ -145,40 +186,60 @@ const DeleteAfter = () => {
|
||||||
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
|
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
|
||||||
const handleChange = async (ev) => {
|
const handleChange = async (ev) => {
|
||||||
await prefs.setDeleteAfter(ev.target.value);
|
await prefs.setDeleteAfter(ev.target.value);
|
||||||
}
|
};
|
||||||
if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0"
|
if (deleteAfter === null || deleteAfter === undefined) {
|
||||||
|
// !deleteAfter will not work with "0"
|
||||||
return null; // While loading
|
return null; // While loading
|
||||||
}
|
}
|
||||||
const description = (() => {
|
const description = (() => {
|
||||||
switch (deleteAfter) {
|
switch (deleteAfter) {
|
||||||
case 0: return t("prefs_notifications_delete_after_never_description");
|
case 0:
|
||||||
case 10800: return t("prefs_notifications_delete_after_three_hours_description");
|
return t("prefs_notifications_delete_after_never_description");
|
||||||
case 86400: return t("prefs_notifications_delete_after_one_day_description");
|
case 10800:
|
||||||
case 604800: return t("prefs_notifications_delete_after_one_week_description");
|
return t("prefs_notifications_delete_after_three_hours_description");
|
||||||
case 2592000: return t("prefs_notifications_delete_after_one_month_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 (
|
return (
|
||||||
<Pref labelId={labelId} title={t("prefs_notifications_delete_after_title")} description={description}>
|
<Pref
|
||||||
|
labelId={labelId}
|
||||||
|
title={t("prefs_notifications_delete_after_title")}
|
||||||
|
description={description}
|
||||||
|
>
|
||||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||||
<Select value={deleteAfter} onChange={handleChange} aria-labelledby={labelId}>
|
<Select
|
||||||
<MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem>
|
value={deleteAfter}
|
||||||
<MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem>
|
onChange={handleChange}
|
||||||
<MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem>
|
aria-labelledby={labelId}
|
||||||
<MenuItem value={604800}>{t("prefs_notifications_delete_after_one_week")}</MenuItem>
|
>
|
||||||
<MenuItem value={2592000}>{t("prefs_notifications_delete_after_one_month")}</MenuItem>
|
<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>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Pref>
|
</Pref>
|
||||||
)
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PrefGroup = (props) => {
|
const PrefGroup = (props) => {
|
||||||
return (
|
return <div role="table">{props.children}</div>;
|
||||||
<div role="table">
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Pref = (props) => {
|
const Pref = (props) => {
|
||||||
|
@ -197,23 +258,29 @@ const Pref = (props) => {
|
||||||
id={props.labelId}
|
id={props.labelId}
|
||||||
aria-label={props.title}
|
aria-label={props.title}
|
||||||
style={{
|
style={{
|
||||||
flex: '1 0 40%',
|
flex: "1 0 40%",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
paddingRight: '30px'
|
paddingRight: "30px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div><b>{props.title}</b></div>
|
<div>
|
||||||
{props.description && <div><em>{props.description}</em></div>}
|
<b>{props.title}</b>
|
||||||
|
</div>
|
||||||
|
{props.description && (
|
||||||
|
<div>
|
||||||
|
<em>{props.description}</em>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
role="cell"
|
role="cell"
|
||||||
style={{
|
style={{
|
||||||
flex: '1 0 calc(60% - 50px)',
|
flex: "1 0 calc(60% - 50px)",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
justifyContent: 'center'
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
@ -228,7 +295,7 @@ const Users = () => {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const users = useLiveQuery(() => userManager.all());
|
const users = useLiveQuery(() => userManager.all());
|
||||||
const handleAddClick = () => {
|
const handleAddClick = () => {
|
||||||
setDialogKey(prev => prev+1);
|
setDialogKey((prev) => prev + 1);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
const handleDialogCancel = () => {
|
const handleDialogCancel = () => {
|
||||||
|
@ -238,7 +305,9 @@ const Users = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
try {
|
try {
|
||||||
await userManager.save(user);
|
await userManager.save(user);
|
||||||
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`);
|
console.debug(
|
||||||
|
`[Preferences] User ${user.username} for ${user.baseUrl} added`,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[Preferences] Error adding user.`, e);
|
console.log(`[Preferences] Error adding user.`, e);
|
||||||
}
|
}
|
||||||
|
@ -249,9 +318,7 @@ const Users = () => {
|
||||||
<Typography variant="h5" sx={{ marginBottom: 2 }}>
|
<Typography variant="h5" sx={{ marginBottom: 2 }}>
|
||||||
{t("prefs_users_title")}
|
{t("prefs_users_title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Paragraph>
|
<Paragraph>{t("prefs_users_description")}</Paragraph>
|
||||||
{t("prefs_users_description")}
|
|
||||||
</Paragraph>
|
|
||||||
{users?.length > 0 && <UserTable users={users} />}
|
{users?.length > 0 && <UserTable users={users} />}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardActions>
|
<CardActions>
|
||||||
|
@ -275,7 +342,7 @@ const UserTable = (props) => {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [dialogUser, setDialogUser] = useState(null);
|
const [dialogUser, setDialogUser] = useState(null);
|
||||||
const handleEditClick = (user) => {
|
const handleEditClick = (user) => {
|
||||||
setDialogKey(prev => prev+1);
|
setDialogKey((prev) => prev + 1);
|
||||||
setDialogUser(user);
|
setDialogUser(user);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
@ -286,7 +353,9 @@ const UserTable = (props) => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
try {
|
try {
|
||||||
await userManager.save(user);
|
await userManager.save(user);
|
||||||
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`);
|
console.debug(
|
||||||
|
`[Preferences] User ${user.username} for ${user.baseUrl} updated`,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[Preferences] Error updating user.`, e);
|
console.log(`[Preferences] Error updating user.`, e);
|
||||||
}
|
}
|
||||||
|
@ -294,7 +363,9 @@ const UserTable = (props) => {
|
||||||
const handleDeleteClick = async (user) => {
|
const handleDeleteClick = async (user) => {
|
||||||
try {
|
try {
|
||||||
await userManager.delete(user.baseUrl);
|
await userManager.delete(user.baseUrl);
|
||||||
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`);
|
console.debug(
|
||||||
|
`[Preferences] User ${user.username} for ${user.baseUrl} deleted`,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
|
console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
|
||||||
}
|
}
|
||||||
|
@ -303,24 +374,41 @@ const UserTable = (props) => {
|
||||||
<Table size="small" aria-label={t("prefs_users_table")}>
|
<Table size="small" aria-label={t("prefs_users_table")}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell>
|
<TableCell sx={{ paddingLeft: 0 }}>
|
||||||
|
{t("prefs_users_table_user_header")}
|
||||||
|
</TableCell>
|
||||||
<TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
|
<TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
|
||||||
<TableCell />
|
<TableCell />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{props.users?.map(user => (
|
{props.users?.map((user) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={user.baseUrl}
|
key={user.baseUrl}
|
||||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
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
|
||||||
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</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">
|
<TableCell align="right">
|
||||||
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
|
<IconButton
|
||||||
|
onClick={() => handleEditClick(user)}
|
||||||
|
aria-label={t("prefs_users_edit_button")}
|
||||||
|
>
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
|
<IconButton
|
||||||
|
onClick={() => handleDeleteClick(user)}
|
||||||
|
aria-label={t("prefs_users_delete_button")}
|
||||||
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
@ -344,25 +432,29 @@ const UserDialog = (props) => {
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
const editMode = props.user !== null;
|
const editMode = props.user !== null;
|
||||||
const addButtonEnabled = (() => {
|
const addButtonEnabled = (() => {
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
return username.length > 0 && password.length > 0;
|
return username.length > 0 && password.length > 0;
|
||||||
}
|
}
|
||||||
const baseUrlValid = validUrl(baseUrl);
|
const baseUrlValid = validUrl(baseUrl);
|
||||||
const baseUrlExists = props.users?.map(user => user.baseUrl).includes(baseUrl);
|
const baseUrlExists = props.users
|
||||||
return baseUrlValid
|
?.map((user) => user.baseUrl)
|
||||||
&& !baseUrlExists
|
.includes(baseUrl);
|
||||||
&& username.length > 0
|
return (
|
||||||
&& password.length > 0;
|
baseUrlValid &&
|
||||||
|
!baseUrlExists &&
|
||||||
|
username.length > 0 &&
|
||||||
|
password.length > 0
|
||||||
|
);
|
||||||
})();
|
})();
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
props.onSubmit({
|
props.onSubmit({
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
username: username,
|
username: username,
|
||||||
password: password
|
password: password,
|
||||||
})
|
});
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
|
@ -373,20 +465,26 @@ const UserDialog = (props) => {
|
||||||
}, [editMode, props.user]);
|
}, [editMode, props.user]);
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||||
<DialogTitle>{editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{editMode
|
||||||
|
? t("prefs_users_dialog_title_edit")
|
||||||
|
: t("prefs_users_dialog_title_add")}
|
||||||
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{!editMode && <TextField
|
{!editMode && (
|
||||||
|
<TextField
|
||||||
autoFocus
|
autoFocus
|
||||||
margin="dense"
|
margin="dense"
|
||||||
id="baseUrl"
|
id="baseUrl"
|
||||||
label={t("prefs_users_dialog_base_url_label")}
|
label={t("prefs_users_dialog_base_url_label")}
|
||||||
aria-label={t("prefs_users_dialog_base_url_label")}
|
aria-label={t("prefs_users_dialog_base_url_label")}
|
||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
onChange={ev => setBaseUrl(ev.target.value)}
|
onChange={(ev) => setBaseUrl(ev.target.value)}
|
||||||
type="url"
|
type="url"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="standard"
|
variant="standard"
|
||||||
/>}
|
/>
|
||||||
|
)}
|
||||||
<TextField
|
<TextField
|
||||||
autoFocus={editMode}
|
autoFocus={editMode}
|
||||||
margin="dense"
|
margin="dense"
|
||||||
|
@ -394,7 +492,7 @@ const UserDialog = (props) => {
|
||||||
label={t("prefs_users_dialog_username_label")}
|
label={t("prefs_users_dialog_username_label")}
|
||||||
aria-label={t("prefs_users_dialog_username_label")}
|
aria-label={t("prefs_users_dialog_username_label")}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={ev => setUsername(ev.target.value)}
|
onChange={(ev) => setUsername(ev.target.value)}
|
||||||
type="text"
|
type="text"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="standard"
|
variant="standard"
|
||||||
|
@ -406,14 +504,20 @@ const UserDialog = (props) => {
|
||||||
aria-label={t("prefs_users_dialog_password_label")}
|
aria-label={t("prefs_users_dialog_password_label")}
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={ev => setPassword(ev.target.value)}
|
onChange={(ev) => setPassword(ev.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="standard"
|
variant="standard"
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={props.onCancel}>{t("prefs_users_dialog_button_cancel")}</Button>
|
<Button onClick={props.onCancel}>
|
||||||
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>{editMode ? t("prefs_users_dialog_button_save") : t("prefs_users_dialog_button_add")}</Button>
|
{t("prefs_users_dialog_button_cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>
|
||||||
|
{editMode
|
||||||
|
? t("prefs_users_dialog_button_save")
|
||||||
|
: t("prefs_users_dialog_button_add")}
|
||||||
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
@ -436,8 +540,28 @@ const Appearance = () => {
|
||||||
const Language = () => {
|
const Language = () => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const labelId = "prefLanguage";
|
const labelId = "prefLanguage";
|
||||||
const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇵🇱", "🇺🇦", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
|
const randomFlags = shuffle([
|
||||||
const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
|
"🇬🇧",
|
||||||
|
"🇺🇸",
|
||||||
|
"🇪🇸",
|
||||||
|
"🇫🇷",
|
||||||
|
"🇧🇬",
|
||||||
|
"🇨🇿",
|
||||||
|
"🇩🇪",
|
||||||
|
"🇵🇱",
|
||||||
|
"🇺🇦",
|
||||||
|
"🇨🇳",
|
||||||
|
"🇮🇹",
|
||||||
|
"🇭🇺",
|
||||||
|
"🇧🇷",
|
||||||
|
"🇳🇱",
|
||||||
|
"🇮🇩",
|
||||||
|
"🇯🇵",
|
||||||
|
"🇷🇺",
|
||||||
|
"🇹🇷",
|
||||||
|
]).slice(0, 3);
|
||||||
|
const title =
|
||||||
|
t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
|
||||||
const lang = i18n.language ?? "en";
|
const lang = i18n.language ?? "en";
|
||||||
|
|
||||||
// Remember: Flags are not languages. Don't put flags next to the language in the list.
|
// Remember: Flags are not languages. Don't put flags next to the language in the list.
|
||||||
|
@ -447,7 +571,11 @@ const Language = () => {
|
||||||
return (
|
return (
|
||||||
<Pref labelId={labelId} title={title}>
|
<Pref labelId={labelId} title={title}>
|
||||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||||
<Select value={lang} onChange={(ev) => i18n.changeLanguage(ev.target.value)} aria-labelledby={labelId}>
|
<Select
|
||||||
|
value={lang}
|
||||||
|
onChange={(ev) => i18n.changeLanguage(ev.target.value)}
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
>
|
||||||
<MenuItem value="en">English</MenuItem>
|
<MenuItem value="en">English</MenuItem>
|
||||||
<MenuItem value="id">Bahasa Indonesia</MenuItem>
|
<MenuItem value="id">Bahasa Indonesia</MenuItem>
|
||||||
<MenuItem value="bg">Български</MenuItem>
|
<MenuItem value="bg">Български</MenuItem>
|
||||||
|
@ -470,7 +598,7 @@ const Language = () => {
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Pref>
|
</Pref>
|
||||||
)
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Preferences;
|
export default Preferences;
|
||||||
|
|
|
@ -1,8 +1,17 @@
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import {useEffect, useRef, useState} from 'react';
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { NotificationItem } from "./Notifications";
|
import { NotificationItem } from "./Notifications";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import {Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, useMediaQuery} from "@mui/material";
|
import {
|
||||||
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
InputLabel,
|
||||||
|
Link,
|
||||||
|
Select,
|
||||||
|
useMediaQuery,
|
||||||
|
} from "@mui/material";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import priority1 from "../img/priority-1.svg";
|
import priority1 from "../img/priority-1.svg";
|
||||||
import priority2 from "../img/priority-2.svg";
|
import priority2 from "../img/priority-2.svg";
|
||||||
|
@ -15,10 +24,18 @@ import DialogContent from "@mui/material/DialogContent";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
|
import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
|
||||||
import { Close } from "@mui/icons-material";
|
import { Close } from "@mui/icons-material";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import {basicAuth, formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils";
|
import {
|
||||||
|
basicAuth,
|
||||||
|
formatBytes,
|
||||||
|
maybeWithBasicAuth,
|
||||||
|
topicShortUrl,
|
||||||
|
topicUrl,
|
||||||
|
validTopic,
|
||||||
|
validUrl,
|
||||||
|
} from "../app/utils";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import AttachmentIcon from "./AttachmentIcon";
|
import AttachmentIcon from "./AttachmentIcon";
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
|
@ -65,10 +82,10 @@ const PublishDialog = (props) => {
|
||||||
const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
|
const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
|
||||||
|
|
||||||
const open = !!props.openMode;
|
const open = !!props.openMode;
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('dragenter', () => {
|
window.addEventListener("dragenter", () => {
|
||||||
props.onDragEnter();
|
props.onDragEnter();
|
||||||
setDropZone(true);
|
setDropZone(true);
|
||||||
});
|
});
|
||||||
|
@ -92,7 +109,7 @@ const PublishDialog = (props) => {
|
||||||
|
|
||||||
const updateBaseUrl = (newVal) => {
|
const updateBaseUrl = (newVal) => {
|
||||||
if (validUrl(newVal)) {
|
if (validUrl(newVal)) {
|
||||||
setBaseUrl(newVal.replace(/\/$/, '')); // strip traililng slash after https?://
|
setBaseUrl(newVal.replace(/\/$/, "")); // strip traililng slash after https?://
|
||||||
} else {
|
} else {
|
||||||
setBaseUrl(newVal);
|
setBaseUrl(newVal);
|
||||||
}
|
}
|
||||||
|
@ -125,19 +142,24 @@ const PublishDialog = (props) => {
|
||||||
url.searchParams.append("delay", delay.trim());
|
url.searchParams.append("delay", delay.trim());
|
||||||
}
|
}
|
||||||
if (attachFile && message.trim()) {
|
if (attachFile && message.trim()) {
|
||||||
url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
|
url.searchParams.append(
|
||||||
|
"message",
|
||||||
|
message.replaceAll("\n", "\\n").trim(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const body = (attachFile) ? attachFile : message;
|
const body = attachFile ? attachFile : message;
|
||||||
try {
|
try {
|
||||||
const user = await userManager.get(baseUrl);
|
const user = await userManager.get(baseUrl);
|
||||||
const headers = maybeWithBasicAuth({}, user);
|
const headers = maybeWithBasicAuth({}, user);
|
||||||
const progressFn = (ev) => {
|
const progressFn = (ev) => {
|
||||||
if (ev.loaded > 0 && ev.total > 0) {
|
if (ev.loaded > 0 && ev.total > 0) {
|
||||||
setStatus(t("publish_dialog_progress_uploading_detail", {
|
setStatus(
|
||||||
|
t("publish_dialog_progress_uploading_detail", {
|
||||||
loaded: formatBytes(ev.loaded),
|
loaded: formatBytes(ev.loaded),
|
||||||
total: formatBytes(ev.total),
|
total: formatBytes(ev.total),
|
||||||
percent: Math.round(ev.loaded * 100.0 / ev.total)
|
percent: Math.round((ev.loaded * 100.0) / ev.total),
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setStatus(t("publish_dialog_progress_uploading"));
|
setStatus(t("publish_dialog_progress_uploading"));
|
||||||
}
|
}
|
||||||
|
@ -152,7 +174,11 @@ const PublishDialog = (props) => {
|
||||||
setActiveRequest(null);
|
setActiveRequest(null);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(<Typography sx={{color: 'error.main', maxWidth: "400px"}}>{e}</Typography>);
|
setStatus(
|
||||||
|
<Typography sx={{ color: "error.main", maxWidth: "400px" }}>
|
||||||
|
{e}
|
||||||
|
</Typography>,
|
||||||
|
);
|
||||||
setActiveRequest(null);
|
setActiveRequest(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -162,17 +188,28 @@ const PublishDialog = (props) => {
|
||||||
const stats = await api.userStats(baseUrl);
|
const stats = await api.userStats(baseUrl);
|
||||||
const fileSizeLimit = stats.attachmentFileSizeLimit ?? 0;
|
const fileSizeLimit = stats.attachmentFileSizeLimit ?? 0;
|
||||||
const remainingBytes = stats.visitorAttachmentBytesRemaining ?? 0;
|
const remainingBytes = stats.visitorAttachmentBytesRemaining ?? 0;
|
||||||
const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;
|
const fileSizeLimitReached =
|
||||||
|
fileSizeLimit > 0 && file.size > fileSizeLimit;
|
||||||
const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
|
const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
|
||||||
if (fileSizeLimitReached && quotaReached) {
|
if (fileSizeLimitReached && quotaReached) {
|
||||||
return setAttachFileError(t("publish_dialog_attachment_limits_file_and_quota_reached", {
|
return setAttachFileError(
|
||||||
|
t("publish_dialog_attachment_limits_file_and_quota_reached", {
|
||||||
fileSizeLimit: formatBytes(fileSizeLimit),
|
fileSizeLimit: formatBytes(fileSizeLimit),
|
||||||
remainingBytes: formatBytes(remainingBytes)
|
remainingBytes: formatBytes(remainingBytes),
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
} else if (fileSizeLimitReached) {
|
} else if (fileSizeLimitReached) {
|
||||||
return setAttachFileError(t("publish_dialog_attachment_limits_file_reached", { fileSizeLimit: formatBytes(fileSizeLimit) }));
|
return setAttachFileError(
|
||||||
|
t("publish_dialog_attachment_limits_file_reached", {
|
||||||
|
fileSizeLimit: formatBytes(fileSizeLimit),
|
||||||
|
}),
|
||||||
|
);
|
||||||
} else if (quotaReached) {
|
} else if (quotaReached) {
|
||||||
return setAttachFileError(t("publish_dialog_attachment_limits_quota_reached", { remainingBytes: formatBytes(remainingBytes) }));
|
return setAttachFileError(
|
||||||
|
t("publish_dialog_attachment_limits_quota_reached", {
|
||||||
|
remainingBytes: formatBytes(remainingBytes),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setAttachFileError("");
|
setAttachFileError("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -214,7 +251,7 @@ const PublishDialog = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmojiPick = (emoji) => {
|
const handleEmojiPick = (emoji) => {
|
||||||
setTags(tags => (tags.trim()) ? `${tags.trim()}, ${emoji}` : emoji);
|
setTags((tags) => (tags.trim() ? `${tags.trim()}, ${emoji}` : emoji));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmojiClose = () => {
|
const handleEmojiClose = () => {
|
||||||
|
@ -226,37 +263,55 @@ const PublishDialog = (props) => {
|
||||||
2: { label: t("publish_dialog_priority_low"), file: priority2 },
|
2: { label: t("publish_dialog_priority_low"), file: priority2 },
|
||||||
3: { label: t("publish_dialog_priority_default"), file: priority3 },
|
3: { label: t("publish_dialog_priority_default"), file: priority3 },
|
||||||
4: { label: t("publish_dialog_priority_high"), file: priority4 },
|
4: { label: t("publish_dialog_priority_high"), file: priority4 },
|
||||||
5: { label: t("publish_dialog_priority_max"), file: priority5 }
|
5: { label: t("publish_dialog_priority_max"), file: priority5 },
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{dropZone && <DropArea
|
{dropZone && (
|
||||||
|
<DropArea
|
||||||
onDrop={handleAttachFileDrop}
|
onDrop={handleAttachFileDrop}
|
||||||
onDragLeave={handleAttachFileDragLeave}/>
|
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>
|
<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>
|
<DialogContent>
|
||||||
{dropZone && <DropBox />}
|
{dropZone && <DropBox />}
|
||||||
{showTopicUrl &&
|
{showTopicUrl && (
|
||||||
<ClosableRow closable={!!props.baseUrl && !!props.topic} disabled={disabled} closeLabel={t("publish_dialog_topic_reset")} onClose={() => {
|
<ClosableRow
|
||||||
|
closable={!!props.baseUrl && !!props.topic}
|
||||||
|
disabled={disabled}
|
||||||
|
closeLabel={t("publish_dialog_topic_reset")}
|
||||||
|
onClose={() => {
|
||||||
setBaseUrl(props.baseUrl);
|
setBaseUrl(props.baseUrl);
|
||||||
setTopic(props.topic);
|
setTopic(props.topic);
|
||||||
setShowTopicUrl(false);
|
setShowTopicUrl(false);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
margin="dense"
|
margin="dense"
|
||||||
label={t("publish_dialog_base_url_label")}
|
label={t("publish_dialog_base_url_label")}
|
||||||
placeholder={t("publish_dialog_base_url_placeholder")}
|
placeholder={t("publish_dialog_base_url_placeholder")}
|
||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
onChange={ev => updateBaseUrl(ev.target.value)}
|
onChange={(ev) => updateBaseUrl(ev.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
type="url"
|
type="url"
|
||||||
variant="standard"
|
variant="standard"
|
||||||
sx={{ flexGrow: 1, marginRight: 1 }}
|
sx={{ flexGrow: 1, marginRight: 1 }}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("publish_dialog_base_url_label")
|
"aria-label": t("publish_dialog_base_url_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
@ -264,30 +319,30 @@ const PublishDialog = (props) => {
|
||||||
label={t("publish_dialog_topic_label")}
|
label={t("publish_dialog_topic_label")}
|
||||||
placeholder={t("publish_dialog_topic_placeholder")}
|
placeholder={t("publish_dialog_topic_placeholder")}
|
||||||
value={topic}
|
value={topic}
|
||||||
onChange={ev => setTopic(ev.target.value)}
|
onChange={(ev) => setTopic(ev.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
type="text"
|
type="text"
|
||||||
variant="standard"
|
variant="standard"
|
||||||
autoFocus={!messageFocused}
|
autoFocus={!messageFocused}
|
||||||
sx={{ flexGrow: 1 }}
|
sx={{ flexGrow: 1 }}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("publish_dialog_topic_label")
|
"aria-label": t("publish_dialog_topic_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ClosableRow>
|
</ClosableRow>
|
||||||
}
|
)}
|
||||||
<TextField
|
<TextField
|
||||||
margin="dense"
|
margin="dense"
|
||||||
label={t("publish_dialog_title_label")}
|
label={t("publish_dialog_title_label")}
|
||||||
placeholder={t("publish_dialog_title_placeholder")}
|
placeholder={t("publish_dialog_title_placeholder")}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={ev => setTitle(ev.target.value)}
|
onChange={(ev) => setTitle(ev.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
type="text"
|
type="text"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="standard"
|
variant="standard"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("publish_dialog_title_label")
|
"aria-label": t("publish_dialog_title_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
@ -295,7 +350,7 @@ const PublishDialog = (props) => {
|
||||||
label={t("publish_dialog_message_label")}
|
label={t("publish_dialog_message_label")}
|
||||||
placeholder={t("publish_dialog_message_placeholder")}
|
placeholder={t("publish_dialog_message_placeholder")}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={ev => setMessage(ev.target.value)}
|
onChange={(ev) => setMessage(ev.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
type="text"
|
type="text"
|
||||||
variant="standard"
|
variant="standard"
|
||||||
|
@ -304,16 +359,20 @@ const PublishDialog = (props) => {
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("publish_dialog_message_label")
|
"aria-label": t("publish_dialog_message_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{display: 'flex'}}>
|
<div style={{ display: "flex" }}>
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
anchorEl={emojiPickerAnchorEl}
|
anchorEl={emojiPickerAnchorEl}
|
||||||
onEmojiPick={handleEmojiPick}
|
onEmojiPick={handleEmojiPick}
|
||||||
onClose={handleEmojiClose}
|
onClose={handleEmojiClose}
|
||||||
/>
|
/>
|
||||||
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
|
<DialogIconButton
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={handleEmojiClick}
|
||||||
|
aria-label={t("publish_dialog_emoji_picker_show")}
|
||||||
|
>
|
||||||
<InsertEmoticonIcon />
|
<InsertEmoticonIcon />
|
||||||
</DialogIconButton>
|
</DialogIconButton>
|
||||||
<TextField
|
<TextField
|
||||||
|
@ -321,13 +380,13 @@ const PublishDialog = (props) => {
|
||||||
label={t("publish_dialog_tags_label")}
|
label={t("publish_dialog_tags_label")}
|
||||||
placeholder={t("publish_dialog_tags_placeholder")}
|
placeholder={t("publish_dialog_tags_placeholder")}
|
||||||
value={tags}
|
value={tags}
|
||||||
onChange={ev => setTags(ev.target.value)}
|
onChange={(ev) => setTags(ev.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
type="text"
|
type="text"
|
||||||
variant="standard"
|
variant="standard"
|
||||||
sx={{ flexGrow: 1, marginRight: 1 }}
|
sx={{ flexGrow: 1, marginRight: 1 }}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("publish_dialog_tags_label")
|
"aria-label": t("publish_dialog_tags_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
|
@ -343,75 +402,99 @@ const PublishDialog = (props) => {
|
||||||
onChange={(ev) => setPriority(ev.target.value)}
|
onChange={(ev) => setPriority(ev.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("publish_dialog_priority_label")
|
"aria-label": t("publish_dialog_priority_label"),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[5,4,3,2,1].map(priority =>
|
{[5, 4, 3, 2, 1].map((priority) => (
|
||||||
<MenuItem key={`priorityMenuItem${priority}`} value={priority} aria-label={t("notifications_priority_x", { priority: priority })}>
|
<MenuItem
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
key={`priorityMenuItem${priority}`}
|
||||||
<img src={priorities[priority].file} style={{marginRight: "8px"}} alt={t("notifications_priority_x", { priority: 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>{priorities[priority].label}</div>
|
||||||
</div>
|
</div>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
{showClickUrl &&
|
{showClickUrl && (
|
||||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_click_reset")} onClose={() => {
|
<ClosableRow
|
||||||
|
disabled={disabled}
|
||||||
|
closeLabel={t("publish_dialog_click_reset")}
|
||||||
|
onClose={() => {
|
||||||
setClickUrl("");
|
setClickUrl("");
|
||||||
setShowClickUrl(false);
|
setShowClickUrl(false);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
margin="dense"
|
margin="dense"
|
||||||
label={t("publish_dialog_click_label")}
|
label={t("publish_dialog_click_label")}
|
||||||
placeholder={t("publish_dialog_click_placeholder")}
|
placeholder={t("publish_dialog_click_placeholder")}
|
||||||
value={clickUrl}
|
value={clickUrl}
|
||||||
onChange={ev => setClickUrl(ev.target.value)}
|
onChange={(ev) => setClickUrl(ev.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
type="url"
|
type="url"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="standard"
|
variant="standard"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("publish_dialog_click_label")
|
"aria-label": t("publish_dialog_click_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ClosableRow>
|
</ClosableRow>
|
||||||
}
|
)}
|
||||||
{showEmail &&
|
{showEmail && (
|
||||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_email_reset")} onClose={() => {
|
<ClosableRow
|
||||||
|
disabled={disabled}
|
||||||
|
closeLabel={t("publish_dialog_email_reset")}
|
||||||
|
onClose={() => {
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setShowEmail(false);
|
setShowEmail(false);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
margin="dense"
|
margin="dense"
|
||||||
label={t("publish_dialog_email_label")}
|
label={t("publish_dialog_email_label")}
|
||||||
placeholder={t("publish_dialog_email_placeholder")}
|
placeholder={t("publish_dialog_email_placeholder")}
|
||||||
value={email}
|
value={email}
|
||||||
onChange={ev => setEmail(ev.target.value)}
|
onChange={(ev) => setEmail(ev.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
type="email"
|
type="email"
|
||||||
variant="standard"
|
variant="standard"
|
||||||
fullWidth
|
fullWidth
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("publish_dialog_email_label")
|
"aria-label": t("publish_dialog_email_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ClosableRow>
|
</ClosableRow>
|
||||||
}
|
)}
|
||||||
{showAttachUrl &&
|
{showAttachUrl && (
|
||||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
|
<ClosableRow
|
||||||
|
disabled={disabled}
|
||||||
|
closeLabel={t("publish_dialog_attach_reset")}
|
||||||
|
onClose={() => {
|
||||||
setAttachUrl("");
|
setAttachUrl("");
|
||||||
setFilename("");
|
setFilename("");
|
||||||
setFilenameEdited(false);
|
setFilenameEdited(false);
|
||||||
setShowAttachUrl(false);
|
setShowAttachUrl(false);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
margin="dense"
|
margin="dense"
|
||||||
label={t("publish_dialog_attach_label")}
|
label={t("publish_dialog_attach_label")}
|
||||||
placeholder={t("publish_dialog_attach_placeholder")}
|
placeholder={t("publish_dialog_attach_placeholder")}
|
||||||
value={attachUrl}
|
value={attachUrl}
|
||||||
onChange={ev => {
|
onChange={(ev) => {
|
||||||
const url = ev.target.value;
|
const url = ev.target.value;
|
||||||
setAttachUrl(url);
|
setAttachUrl(url);
|
||||||
if (!filenameEdited) {
|
if (!filenameEdited) {
|
||||||
|
@ -431,7 +514,7 @@ const PublishDialog = (props) => {
|
||||||
variant="standard"
|
variant="standard"
|
||||||
sx={{ flexGrow: 5, marginRight: 1 }}
|
sx={{ flexGrow: 5, marginRight: 1 }}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("publish_dialog_attach_label")
|
"aria-label": t("publish_dialog_attach_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
@ -439,7 +522,7 @@ const PublishDialog = (props) => {
|
||||||
label={t("publish_dialog_filename_label")}
|
label={t("publish_dialog_filename_label")}
|
||||||
placeholder={t("publish_dialog_filename_placeholder")}
|
placeholder={t("publish_dialog_filename_placeholder")}
|
||||||
value={filename}
|
value={filename}
|
||||||
onChange={ev => {
|
onChange={(ev) => {
|
||||||
setFilename(ev.target.value);
|
setFilename(ev.target.value);
|
||||||
setFilenameEdited(true);
|
setFilenameEdited(true);
|
||||||
}}
|
}}
|
||||||
|
@ -448,19 +531,20 @@ const PublishDialog = (props) => {
|
||||||
variant="standard"
|
variant="standard"
|
||||||
sx={{ flexGrow: 1 }}
|
sx={{ flexGrow: 1 }}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("publish_dialog_filename_label")
|
"aria-label": t("publish_dialog_filename_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ClosableRow>
|
</ClosableRow>
|
||||||
}
|
)}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={attachFileInput}
|
ref={attachFileInput}
|
||||||
onChange={handleAttachFileChanged}
|
onChange={handleAttachFileChanged}
|
||||||
style={{ display: 'none' }}
|
style={{ display: "none" }}
|
||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
/>
|
/>
|
||||||
{showAttachFile && <AttachmentBox
|
{showAttachFile && (
|
||||||
|
<AttachmentBox
|
||||||
file={attachFile}
|
file={attachFile}
|
||||||
filename={filename}
|
filename={filename}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
@ -471,55 +555,124 @@ const PublishDialog = (props) => {
|
||||||
setAttachFileError("");
|
setAttachFileError("");
|
||||||
setFilename("");
|
setFilename("");
|
||||||
}}
|
}}
|
||||||
/>}
|
/>
|
||||||
{showDelay &&
|
)}
|
||||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_delay_reset")} onClose={() => {
|
{showDelay && (
|
||||||
|
<ClosableRow
|
||||||
|
disabled={disabled}
|
||||||
|
closeLabel={t("publish_dialog_delay_reset")}
|
||||||
|
onClose={() => {
|
||||||
setDelay("");
|
setDelay("");
|
||||||
setShowDelay(false);
|
setShowDelay(false);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
margin="dense"
|
margin="dense"
|
||||||
label={t("publish_dialog_delay_label")}
|
label={t("publish_dialog_delay_label")}
|
||||||
placeholder={t("publish_dialog_delay_placeholder", {
|
placeholder={t("publish_dialog_delay_placeholder", {
|
||||||
unixTimestamp: "1649029748",
|
unixTimestamp: "1649029748",
|
||||||
relativeTime: "30m",
|
relativeTime: "30m",
|
||||||
naturalLanguage: "tomorrow, 9am"
|
naturalLanguage: "tomorrow, 9am",
|
||||||
})}
|
})}
|
||||||
value={delay}
|
value={delay}
|
||||||
onChange={ev => setDelay(ev.target.value)}
|
onChange={(ev) => setDelay(ev.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
type="text"
|
type="text"
|
||||||
variant="standard"
|
variant="standard"
|
||||||
fullWidth
|
fullWidth
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("publish_dialog_delay_label")
|
"aria-label": t("publish_dialog_delay_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ClosableRow>
|
</ClosableRow>
|
||||||
}
|
)}
|
||||||
<Typography variant="body1" sx={{ marginTop: 2, marginBottom: 1 }}>
|
<Typography variant="body1" sx={{ marginTop: 2, marginBottom: 1 }}>
|
||||||
{t("publish_dialog_other_features")}
|
{t("publish_dialog_other_features")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<div>
|
<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}}/>}
|
{!showClickUrl && (
|
||||||
{!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}}/>}
|
<Chip
|
||||||
{!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}}/>}
|
clickable
|
||||||
{!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}}/>}
|
disabled={disabled}
|
||||||
{!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}}/>}
|
label={t("publish_dialog_chip_click_label")}
|
||||||
{!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}}/>}
|
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 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!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 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Typography variant="body1" sx={{ marginTop: 1, marginBottom: 1 }}>
|
<Typography variant="body1" sx={{ marginTop: 1, marginBottom: 1 }}>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="publish_dialog_details_examples_description"
|
i18nKey="publish_dialog_details_examples_description"
|
||||||
components={{
|
components={{
|
||||||
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/>
|
docsLink: (
|
||||||
|
<Link
|
||||||
|
href="https://ntfy.sh/docs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
/>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Typography>
|
</Typography>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter status={status}>
|
<DialogFooter status={status}>
|
||||||
{activeRequest && <Button onClick={() => activeRequest.abort()}>{t("publish_dialog_button_cancel_sending")}</Button>}
|
{activeRequest && (
|
||||||
{!activeRequest &&
|
<Button onClick={() => activeRequest.abort()}>
|
||||||
|
{t("publish_dialog_button_cancel_sending")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!activeRequest && (
|
||||||
<>
|
<>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
label={t("publish_dialog_checkbox_publish_another")}
|
label={t("publish_dialog_checkbox_publish_another")}
|
||||||
|
@ -530,13 +683,21 @@ const PublishDialog = (props) => {
|
||||||
checked={publishAnother}
|
checked={publishAnother}
|
||||||
onChange={(ev) => setPublishAnother(ev.target.checked)}
|
onChange={(ev) => setPublishAnother(ev.target.checked)}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("publish_dialog_checkbox_publish_another")
|
"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>
|
/>
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
|
/>
|
||||||
|
<Button onClick={props.onClose}>
|
||||||
|
{t("publish_dialog_button_cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>
|
||||||
|
{t("publish_dialog_button_send")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
|
@ -545,22 +706,27 @@ const PublishDialog = (props) => {
|
||||||
|
|
||||||
const Row = (props) => {
|
const Row = (props) => {
|
||||||
return (
|
return (
|
||||||
<div style={{display: 'flex'}} role="row">
|
<div style={{ display: "flex" }} role="row">
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ClosableRow = (props) => {
|
const ClosableRow = (props) => {
|
||||||
const closable = (props.hasOwnProperty("closable")) ? props.closable : true;
|
const closable = props.hasOwnProperty("closable") ? props.closable : true;
|
||||||
return (
|
return (
|
||||||
<Row>
|
<Row>
|
||||||
{props.children}
|
{props.children}
|
||||||
{closable &&
|
{closable && (
|
||||||
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={props.closeLabel}>
|
<DialogIconButton
|
||||||
|
disabled={props.disabled}
|
||||||
|
onClick={props.onClose}
|
||||||
|
sx={{ marginLeft: "6px" }}
|
||||||
|
aria-label={props.closeLabel}
|
||||||
|
>
|
||||||
<Close />
|
<Close />
|
||||||
</DialogIconButton>
|
</DialogIconButton>
|
||||||
}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -590,14 +756,16 @@ const AttachmentBox = (props) => {
|
||||||
<Typography variant="body1" sx={{ marginTop: 2 }}>
|
<Typography variant="body1" sx={{ marginTop: 2 }}>
|
||||||
{t("publish_dialog_attached_file_title")}
|
{t("publish_dialog_attached_file_title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{
|
<Box
|
||||||
display: 'flex',
|
sx={{
|
||||||
alignItems: 'center',
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
padding: 0.5,
|
padding: 0.5,
|
||||||
borderRadius: '4px',
|
borderRadius: "4px",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<AttachmentIcon type={file.type} />
|
<AttachmentIcon type={file.type} />
|
||||||
<Box sx={{ marginLeft: 1, textAlign: 'left' }}>
|
<Box sx={{ marginLeft: 1, textAlign: "left" }}>
|
||||||
<ExpandingTextField
|
<ExpandingTextField
|
||||||
minWidth={140}
|
minWidth={140}
|
||||||
variant="body2"
|
variant="body2"
|
||||||
|
@ -607,16 +775,26 @@ const AttachmentBox = (props) => {
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
<Typography variant="body2" sx={{ color: 'text.primary' }}>
|
<Typography variant="body2" sx={{ color: "text.primary" }}>
|
||||||
{formatBytes(file.size)}
|
{formatBytes(file.size)}
|
||||||
{props.error &&
|
{props.error && (
|
||||||
<Typography component="span" sx={{ color: 'error.main' }} aria-live="polite">
|
<Typography
|
||||||
{" "}({props.error})
|
component="span"
|
||||||
|
sx={{ color: "error.main" }}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
({props.error})
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={t("publish_dialog_attached_file_remove")}>
|
<DialogIconButton
|
||||||
|
disabled={props.disabled}
|
||||||
|
onClick={props.onClose}
|
||||||
|
sx={{ marginLeft: "6px" }}
|
||||||
|
aria-label={t("publish_dialog_attached_file_remove")}
|
||||||
|
>
|
||||||
<Close />
|
<Close />
|
||||||
</DialogIconButton>
|
</DialogIconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
@ -632,7 +810,9 @@ const ExpandingTextField = (props) => {
|
||||||
if (!boundingRect) {
|
if (!boundingRect) {
|
||||||
return props.minWidth;
|
return props.minWidth;
|
||||||
}
|
}
|
||||||
return (boundingRect.width >= props.minWidth) ? Math.round(boundingRect.width) : props.minWidth;
|
return boundingRect.width >= props.minWidth
|
||||||
|
? Math.round(boundingRect.width)
|
||||||
|
: props.minWidth;
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTextWidth(determineTextWidth() + 5);
|
setTextWidth(determineTextWidth() + 5);
|
||||||
|
@ -656,15 +836,17 @@ const ExpandingTextField = (props) => {
|
||||||
type="text"
|
type="text"
|
||||||
variant="standard"
|
variant="standard"
|
||||||
sx={{ width: `${textWidth}px`, borderBottom: "none" }}
|
sx={{ width: `${textWidth}px`, borderBottom: "none" }}
|
||||||
InputProps={{ style: { fontSize: theme.typography[props.variant].fontSize } }}
|
InputProps={{
|
||||||
|
style: { fontSize: theme.typography[props.variant].fontSize },
|
||||||
|
}}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
style: { paddingBottom: 0, paddingTop: 0 },
|
style: { paddingBottom: 0, paddingTop: 0 },
|
||||||
"aria-label": props.placeholder
|
"aria-label": props.placeholder,
|
||||||
}}
|
}}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DropArea = (props) => {
|
const DropArea = (props) => {
|
||||||
|
@ -672,14 +854,14 @@ const DropArea = (props) => {
|
||||||
// This is where we could disallow certain files to be dragged in.
|
// This is where we could disallow certain files to be dragged in.
|
||||||
// For now we allow all files.
|
// For now we allow all files.
|
||||||
|
|
||||||
ev.dataTransfer.dropEffect = 'copy';
|
ev.dataTransfer.dropEffect = "copy";
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
|
@ -697,35 +879,39 @@ const DropArea = (props) => {
|
||||||
const DropBox = () => {
|
const DropBox = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box sx={{
|
<Box
|
||||||
position: 'absolute',
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
zIndex: 10000,
|
zIndex: 10000,
|
||||||
backgroundColor: "#ffffffbb"
|
backgroundColor: "#ffffffbb",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
border: '3px dashed #ccc',
|
border: "3px dashed #ccc",
|
||||||
borderRadius: '5px',
|
borderRadius: "5px",
|
||||||
left: "40px",
|
left: "40px",
|
||||||
top: "40px",
|
top: "40px",
|
||||||
right: "40px",
|
right: "40px",
|
||||||
bottom: "40px",
|
bottom: "40px",
|
||||||
zIndex: 10001,
|
zIndex: 10001,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h5">{t("publish_dialog_drop_file_here")}</Typography>
|
<Typography variant="h5">
|
||||||
|
{t("publish_dialog_drop_file_here")}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
PublishDialog.OPEN_MODE_DEFAULT = "default";
|
PublishDialog.OPEN_MODE_DEFAULT = "default";
|
||||||
PublishDialog.OPEN_MODE_DRAG = "drag";
|
PublishDialog.OPEN_MODE_DRAG = "drag";
|
||||||
|
|
|
@ -1,15 +1,25 @@
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import {useState} from 'react';
|
import { useState } from "react";
|
||||||
import Button from '@mui/material/Button';
|
import Button from "@mui/material/Button";
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from "@mui/material/TextField";
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from "@mui/material/Dialog";
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
import DialogContentText from '@mui/material/DialogContentText';
|
import DialogContentText from "@mui/material/DialogContentText";
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material";
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel,
|
||||||
|
useMediaQuery,
|
||||||
|
} from "@mui/material";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import api from "../app/Api";
|
import api from "../app/Api";
|
||||||
import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils";
|
import {
|
||||||
|
randomAlphanumericString,
|
||||||
|
topicUrl,
|
||||||
|
validTopic,
|
||||||
|
validUrl,
|
||||||
|
} from "../app/utils";
|
||||||
import userManager from "../app/UserManager";
|
import userManager from "../app/UserManager";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import poller from "../app/Poller";
|
import poller from "../app/Poller";
|
||||||
|
@ -22,16 +32,17 @@ const SubscribeDialog = (props) => {
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
const [showLoginPage, setShowLoginPage] = useState(false);
|
const [showLoginPage, setShowLoginPage] = useState(false);
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
const handleSuccess = async () => {
|
const handleSuccess = async () => {
|
||||||
const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin;
|
const actualBaseUrl = baseUrl ? baseUrl : window.location.origin;
|
||||||
const subscription = await subscriptionManager.add(actualBaseUrl, topic);
|
const subscription = await subscriptionManager.add(actualBaseUrl, topic);
|
||||||
poller.pollInBackground(subscription); // Dangle!
|
poller.pollInBackground(subscription); // Dangle!
|
||||||
props.onSuccess(subscription);
|
props.onSuccess(subscription);
|
||||||
}
|
};
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||||
{!showLoginPage && <SubscribePage
|
{!showLoginPage && (
|
||||||
|
<SubscribePage
|
||||||
baseUrl={baseUrl}
|
baseUrl={baseUrl}
|
||||||
setBaseUrl={setBaseUrl}
|
setBaseUrl={setBaseUrl}
|
||||||
topic={topic}
|
topic={topic}
|
||||||
|
@ -40,13 +51,16 @@ const SubscribeDialog = (props) => {
|
||||||
onCancel={props.onCancel}
|
onCancel={props.onCancel}
|
||||||
onNeedsLogin={() => setShowLoginPage(true)}
|
onNeedsLogin={() => setShowLoginPage(true)}
|
||||||
onSuccess={handleSuccess}
|
onSuccess={handleSuccess}
|
||||||
/>}
|
/>
|
||||||
{showLoginPage && <LoginPage
|
)}
|
||||||
|
{showLoginPage && (
|
||||||
|
<LoginPage
|
||||||
baseUrl={baseUrl}
|
baseUrl={baseUrl}
|
||||||
topic={topic}
|
topic={topic}
|
||||||
onBack={() => setShowLoginPage(false)}
|
onBack={() => setShowLoginPage(false)}
|
||||||
onSuccess={handleSuccess}
|
onSuccess={handleSuccess}
|
||||||
/>}
|
/>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -55,26 +69,45 @@ const SubscribePage = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [anotherServerVisible, setAnotherServerVisible] = useState(false);
|
const [anotherServerVisible, setAnotherServerVisible] = useState(false);
|
||||||
const [errorText, setErrorText] = useState("");
|
const [errorText, setErrorText] = useState("");
|
||||||
const baseUrl = (anotherServerVisible) ? props.baseUrl : window.location.origin;
|
const baseUrl = anotherServerVisible ? props.baseUrl : window.location.origin;
|
||||||
const topic = props.topic;
|
const topic = props.topic;
|
||||||
const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic));
|
const existingTopicUrls = props.subscriptions.map((s) =>
|
||||||
const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)]))
|
topicUrl(s.baseUrl, s.topic),
|
||||||
.filter(s => s !== window.location.origin);
|
);
|
||||||
|
const existingBaseUrls = Array.from(
|
||||||
|
new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)]),
|
||||||
|
).filter((s) => s !== window.location.origin);
|
||||||
const handleSubscribe = async () => {
|
const handleSubscribe = async () => {
|
||||||
const user = await userManager.get(baseUrl); // May be undefined
|
const user = await userManager.get(baseUrl); // May be undefined
|
||||||
const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous");
|
const username = user
|
||||||
|
? user.username
|
||||||
|
: t("subscribe_dialog_error_user_anonymous");
|
||||||
const success = await api.auth(baseUrl, topic, user);
|
const success = await api.auth(baseUrl, topic, user);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
console.log(
|
||||||
|
`[SubscribeDialog] Login to ${topicUrl(
|
||||||
|
baseUrl,
|
||||||
|
topic,
|
||||||
|
)} failed for user ${username}`,
|
||||||
|
);
|
||||||
if (user) {
|
if (user) {
|
||||||
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));
|
setErrorText(
|
||||||
|
t("subscribe_dialog_error_user_not_authorized", {
|
||||||
|
username: username,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
props.onNeedsLogin();
|
props.onNeedsLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
|
console.log(
|
||||||
|
`[SubscribeDialog] Successful login to ${topicUrl(
|
||||||
|
baseUrl,
|
||||||
|
topic,
|
||||||
|
)} for user ${username}`,
|
||||||
|
);
|
||||||
props.onSuccess();
|
props.onSuccess();
|
||||||
};
|
};
|
||||||
const handleUseAnotherChanged = (e) => {
|
const handleUseAnotherChanged = (e) => {
|
||||||
|
@ -83,16 +116,20 @@ const SubscribePage = (props) => {
|
||||||
};
|
};
|
||||||
const subscribeButtonEnabled = (() => {
|
const subscribeButtonEnabled = (() => {
|
||||||
if (anotherServerVisible) {
|
if (anotherServerVisible) {
|
||||||
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
|
const isExistingTopicUrl = existingTopicUrls.includes(
|
||||||
|
topicUrl(baseUrl, topic),
|
||||||
|
);
|
||||||
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
|
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
|
||||||
} else {
|
} else {
|
||||||
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(window.location.origin, topic));
|
const isExistingTopicUrl = existingTopicUrls.includes(
|
||||||
|
topicUrl(window.location.origin, topic),
|
||||||
|
);
|
||||||
return validTopic(topic) && !isExistingTopicUrl;
|
return validTopic(topic) && !isExistingTopicUrl;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const updateBaseUrl = (ev, newVal) => {
|
const updateBaseUrl = (ev, newVal) => {
|
||||||
if (validUrl(newVal)) {
|
if (validUrl(newVal)) {
|
||||||
props.setBaseUrl(newVal.replace(/\/$/, '')); // strip trailing slash after https?://
|
props.setBaseUrl(newVal.replace(/\/$/, "")); // strip trailing slash after https?://
|
||||||
} else {
|
} else {
|
||||||
props.setBaseUrl(newVal);
|
props.setBaseUrl(newVal);
|
||||||
}
|
}
|
||||||
|
@ -104,23 +141,28 @@ const SubscribePage = (props) => {
|
||||||
<DialogContentText>
|
<DialogContentText>
|
||||||
{t("subscribe_dialog_subscribe_description")}
|
{t("subscribe_dialog_subscribe_description")}
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
<div style={{display: 'flex'}} role="row">
|
<div style={{ display: "flex" }} role="row">
|
||||||
<TextField
|
<TextField
|
||||||
autoFocus
|
autoFocus
|
||||||
margin="dense"
|
margin="dense"
|
||||||
id="topic"
|
id="topic"
|
||||||
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
|
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
|
||||||
value={props.topic}
|
value={props.topic}
|
||||||
onChange={ev => props.setTopic(ev.target.value)}
|
onChange={(ev) => props.setTopic(ev.target.value)}
|
||||||
type="text"
|
type="text"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="standard"
|
variant="standard"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
maxLength: 64,
|
maxLength: 64,
|
||||||
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder")
|
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => {props.setTopic(randomAlphanumericString(16))}} style={{flexShrink: "0", marginTop: "0.5em"}}>
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
props.setTopic(randomAlphanumericString(16));
|
||||||
|
}}
|
||||||
|
style={{ flexShrink: "0", marginTop: "0.5em" }}
|
||||||
|
>
|
||||||
{t("subscribe_dialog_subscribe_button_generate_topic_name")}
|
{t("subscribe_dialog_subscribe_button_generate_topic_name")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -130,30 +172,37 @@ const SubscribePage = (props) => {
|
||||||
<Checkbox
|
<Checkbox
|
||||||
onChange={handleUseAnotherChanged}
|
onChange={handleUseAnotherChanged}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("subscribe_dialog_subscribe_use_another_label")
|
"aria-label": t("subscribe_dialog_subscribe_use_another_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={t("subscribe_dialog_subscribe_use_another_label")} />
|
label={t("subscribe_dialog_subscribe_use_another_label")}
|
||||||
{anotherServerVisible && <Autocomplete
|
/>
|
||||||
|
{anotherServerVisible && (
|
||||||
|
<Autocomplete
|
||||||
freeSolo
|
freeSolo
|
||||||
options={existingBaseUrls}
|
options={existingBaseUrls}
|
||||||
sx={{ maxWidth: 400 }}
|
sx={{ maxWidth: 400 }}
|
||||||
inputValue={props.baseUrl}
|
inputValue={props.baseUrl}
|
||||||
onInputChange={updateBaseUrl}
|
onInputChange={updateBaseUrl}
|
||||||
renderInput={ (params) =>
|
renderInput={(params) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...params}
|
{...params}
|
||||||
placeholder={window.location.origin}
|
placeholder={window.location.origin}
|
||||||
variant="standard"
|
variant="standard"
|
||||||
aria-label={t("subscribe_dialog_subscribe_base_url_label")}
|
aria-label={t("subscribe_dialog_subscribe_base_url_label")}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
/>}
|
/>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter status={errorText}>
|
<DialogFooter status={errorText}>
|
||||||
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
|
<Button onClick={props.onCancel}>
|
||||||
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>{t("subscribe_dialog_subscribe_button_subscribe")}</Button>
|
{t("subscribe_dialog_subscribe_button_cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>
|
||||||
|
{t("subscribe_dialog_subscribe_button_subscribe")}
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -164,17 +213,29 @@ const LoginPage = (props) => {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [errorText, setErrorText] = useState("");
|
const [errorText, setErrorText] = useState("");
|
||||||
const baseUrl = (props.baseUrl) ? props.baseUrl : window.location.origin;
|
const baseUrl = props.baseUrl ? props.baseUrl : window.location.origin;
|
||||||
const topic = props.topic;
|
const topic = props.topic;
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
const user = { baseUrl, username, password };
|
const user = { baseUrl, username, password };
|
||||||
const success = await api.auth(baseUrl, topic, user);
|
const success = await api.auth(baseUrl, topic, user);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
console.log(
|
||||||
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));
|
`[SubscribeDialog] Login to ${topicUrl(
|
||||||
|
baseUrl,
|
||||||
|
topic,
|
||||||
|
)} failed for user ${username}`,
|
||||||
|
);
|
||||||
|
setErrorText(
|
||||||
|
t("subscribe_dialog_error_user_not_authorized", { username: username }),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
|
console.log(
|
||||||
|
`[SubscribeDialog] Successful login to ${topicUrl(
|
||||||
|
baseUrl,
|
||||||
|
topic,
|
||||||
|
)} for user ${username}`,
|
||||||
|
);
|
||||||
await userManager.save(user);
|
await userManager.save(user);
|
||||||
props.onSuccess();
|
props.onSuccess();
|
||||||
};
|
};
|
||||||
|
@ -191,12 +252,12 @@ const LoginPage = (props) => {
|
||||||
id="username"
|
id="username"
|
||||||
label={t("subscribe_dialog_login_username_label")}
|
label={t("subscribe_dialog_login_username_label")}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={ev => setUsername(ev.target.value)}
|
onChange={(ev) => setUsername(ev.target.value)}
|
||||||
type="text"
|
type="text"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="standard"
|
variant="standard"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("subscribe_dialog_login_username_label")
|
"aria-label": t("subscribe_dialog_login_username_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
@ -205,17 +266,21 @@ const LoginPage = (props) => {
|
||||||
label={t("subscribe_dialog_login_password_label")}
|
label={t("subscribe_dialog_login_password_label")}
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={ev => setPassword(ev.target.value)}
|
onChange={(ev) => setPassword(ev.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="standard"
|
variant="standard"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("subscribe_dialog_login_password_label")
|
"aria-label": t("subscribe_dialog_login_password_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter status={errorText}>
|
<DialogFooter status={errorText}>
|
||||||
<Button onClick={props.onBack}>{t("subscribe_dialog_login_button_back")}</Button>
|
<Button onClick={props.onBack}>
|
||||||
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
|
{t("subscribe_dialog_login_button_back")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleLogin}>
|
||||||
|
{t("subscribe_dialog_login_button_login")}
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import {useState} from 'react';
|
import { useState } from "react";
|
||||||
import Button from '@mui/material/Button';
|
import Button from "@mui/material/Button";
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from "@mui/material/TextField";
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from "@mui/material/Dialog";
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
import DialogContentText from '@mui/material/DialogContentText';
|
import DialogContentText from "@mui/material/DialogContentText";
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material";
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel,
|
||||||
|
useMediaQuery,
|
||||||
|
} from "@mui/material";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import api from "../app/Api";
|
import api from "../app/Api";
|
||||||
import { topicUrl, validTopic, validUrl } from "../app/utils";
|
import { topicUrl, validTopic, validUrl } from "../app/utils";
|
||||||
|
@ -19,12 +24,14 @@ import {useTranslation} from "react-i18next";
|
||||||
const SubscriptionSettingsDialog = (props) => {
|
const SubscriptionSettingsDialog = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const subscription = props.subscription;
|
const subscription = props.subscription;
|
||||||
const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
|
const [displayName, setDisplayName] = useState(
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
subscription.displayName ?? "",
|
||||||
|
);
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
await subscriptionManager.setDisplayName(subscription.id, displayName);
|
await subscriptionManager.setDisplayName(subscription.id, displayName);
|
||||||
props.onClose();
|
props.onClose();
|
||||||
}
|
};
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
|
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
|
||||||
<DialogTitle>{t("subscription_settings_dialog_title")}</DialogTitle>
|
<DialogTitle>{t("subscription_settings_dialog_title")}</DialogTitle>
|
||||||
|
@ -36,21 +43,29 @@ const SubscriptionSettingsDialog = (props) => {
|
||||||
autoFocus
|
autoFocus
|
||||||
margin="dense"
|
margin="dense"
|
||||||
id="topic"
|
id="topic"
|
||||||
placeholder={t("subscription_settings_dialog_display_name_placeholder")}
|
placeholder={t(
|
||||||
|
"subscription_settings_dialog_display_name_placeholder",
|
||||||
|
)}
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={ev => setDisplayName(ev.target.value)}
|
onChange={(ev) => setDisplayName(ev.target.value)}
|
||||||
type="text"
|
type="text"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="standard"
|
variant="standard"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
maxLength: 64,
|
maxLength: 64,
|
||||||
"aria-label": t("subscription_settings_dialog_display_name_placeholder")
|
"aria-label": t(
|
||||||
|
"subscription_settings_dialog_display_name_placeholder",
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button onClick={props.onClose}>{t("subscription_settings_button_cancel")}</Button>
|
<Button onClick={props.onClose}>
|
||||||
<Button onClick={handleSave}>{t("subscription_settings_button_save")}</Button>
|
{t("subscription_settings_button_cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>
|
||||||
|
{t("subscription_settings_button_save")}
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
@ -16,12 +16,21 @@ import pruner from "../app/Pruner";
|
||||||
export const useConnectionListeners = (subscriptions, users) => {
|
export const useConnectionListeners = (subscriptions, users) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(
|
||||||
|
() => {
|
||||||
const handleNotification = async (subscriptionId, notification) => {
|
const handleNotification = async (subscriptionId, notification) => {
|
||||||
const added = await subscriptionManager.addNotification(subscriptionId, notification);
|
const added = await subscriptionManager.addNotification(
|
||||||
|
subscriptionId,
|
||||||
|
notification,
|
||||||
|
);
|
||||||
if (added) {
|
if (added) {
|
||||||
const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription));
|
const defaultClickAction = (subscription) =>
|
||||||
await notifier.notify(subscriptionId, notification, defaultClickAction)
|
navigate(routes.forSubscription(subscription));
|
||||||
|
await notifier.notify(
|
||||||
|
subscriptionId,
|
||||||
|
notification,
|
||||||
|
defaultClickAction,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
connectionManager.registerStateListener(subscriptionManager.updateState);
|
connectionManager.registerStateListener(subscriptionManager.updateState);
|
||||||
|
@ -29,11 +38,11 @@ export const useConnectionListeners = (subscriptions, users) => {
|
||||||
return () => {
|
return () => {
|
||||||
connectionManager.resetStateListener();
|
connectionManager.resetStateListener();
|
||||||
connectionManager.resetNotificationListener();
|
connectionManager.resetNotificationListener();
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
// We have to disable dep checking for "navigate". This is fine, it never changes.
|
// We have to disable dep checking for "navigate". This is fine, it never changes.
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -55,12 +64,20 @@ export const useAutoSubscribe = (subscriptions, selected) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setHasRun(true);
|
setHasRun(true);
|
||||||
const eligible = params.topic && !selected && !disallowedTopic(params.topic);
|
const eligible =
|
||||||
|
params.topic && !selected && !disallowedTopic(params.topic);
|
||||||
if (eligible) {
|
if (eligible) {
|
||||||
const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : window.location.origin;
|
const baseUrl = params.baseUrl
|
||||||
console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
|
? expandSecureUrl(params.baseUrl)
|
||||||
|
: window.location.origin;
|
||||||
|
console.log(
|
||||||
|
`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`,
|
||||||
|
);
|
||||||
(async () => {
|
(async () => {
|
||||||
const subscription = await subscriptionManager.add(baseUrl, params.topic);
|
const subscription = await subscriptionManager.add(
|
||||||
|
baseUrl,
|
||||||
|
params.topic,
|
||||||
|
);
|
||||||
poller.pollInBackground(subscription); // Dangle!
|
poller.pollInBackground(subscription); // Dangle!
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
@ -77,4 +94,4 @@ export const useBackgroundProcesses = () => {
|
||||||
poller.startWorker();
|
poller.startWorker();
|
||||||
pruner.startWorker();
|
pruner.startWorker();
|
||||||
}, []);
|
}, []);
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import i18n from 'i18next';
|
import i18n from "i18next";
|
||||||
import Backend from 'i18next-http-backend';
|
import Backend from "i18next-http-backend";
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
// Translations using i18next
|
// Translations using i18next
|
||||||
// - Options: https://www.i18next.com/overview/configuration-options
|
// - Options: https://www.i18next.com/overview/configuration-options
|
||||||
|
@ -16,14 +16,14 @@ i18n
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
fallbackLng: 'en',
|
fallbackLng: "en",
|
||||||
debug: true,
|
debug: true,
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false, // not needed for react as it escapes by default
|
escapeValue: false, // not needed for react as it escapes by default
|
||||||
},
|
},
|
||||||
backend: {
|
backend: {
|
||||||
loadPath: '/static/langs/{{lng}}.json',
|
loadPath: "/static/langs/{{lng}}.json",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|
|
@ -11,7 +11,7 @@ const routes = {
|
||||||
return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;
|
return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;
|
||||||
}
|
}
|
||||||
return `/${subscription.topic}`;
|
return `/${subscription.topic}`;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default routes;
|
export default routes;
|
||||||
|
|
|
@ -9,14 +9,14 @@ export const Paragraph = styled(Typography)({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const VerticallyCenteredContainer = styled(Container)({
|
export const VerticallyCenteredContainer = styled(Container)({
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
alignContent: 'center',
|
alignContent: "center",
|
||||||
color: theme.palette.text.primary
|
color: theme.palette.text.primary,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LightboxBackdrop = styled(Backdrop)({
|
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 { red } from "@mui/material/colors";
|
||||||
import { createTheme } from '@mui/material/styles';
|
import { createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
main: '#338574',
|
main: "#338574",
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
main: '#6cead0',
|
main: "#6cead0",
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
main: red.A400,
|
main: red.A400,
|
||||||
|
@ -17,19 +17,19 @@ const theme = createTheme({
|
||||||
MuiListItemIcon: {
|
MuiListItemIcon: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
minWidth: '36px',
|
minWidth: "36px",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
MuiCardContent: {
|
MuiCardContent: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
':last-child': {
|
":last-child": {
|
||||||
paddingBottom: '16px'
|
paddingBottom: "16px",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from "react-dom/client";
|
||||||
import App from './components/App';
|
import App from "./components/App";
|
||||||
|
|
||||||
const root = createRoot(document.querySelector('#root'));
|
const root = createRoot(document.querySelector("#root"));
|
||||||
root.render(<App />);
|
root.render(<App />);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue