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:
Nick Farrell 2022-12-17 18:22:37 +11:00
parent 108ad3c7c3
commit b218e62ffc
No known key found for this signature in database
GPG key ID: 740D3A86CF435835
151 changed files with 42251 additions and 31034 deletions

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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 (

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -1,8 +1,9 @@
package client package client
import ( import (
"gopkg.in/yaml.v2"
"os" "os"
"gopkg.in/yaml.v2"
) )
const ( const (

View file

@ -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) {

View file

@ -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

View file

@ -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"

View file

@ -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) {

View file

@ -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 (

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -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() {

View file

@ -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) {

View file

@ -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() {

View file

@ -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) {

View file

@ -14,4 +14,3 @@ services:
ports: ports:
- 80:80 - 80:80
restart: unless-stopped restart: unless-stopped

View file

@ -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]
``` ```

View file

@ -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
``` ```

View file

@ -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`):
![Rundeck](static/img/rundeck.png) ![Rundeck](static/img/rundeck.png)

View file

@ -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>

View file

@ -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;

View file

@ -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);
};
}); });
}); });

View file

@ -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>

View file

@ -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 (

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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 (

View file

@ -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) {

View file

@ -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) {

View file

@ -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 (

View file

@ -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

View file

@ -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 (

View file

@ -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:

View file

@ -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)

View file

@ -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 {

View file

@ -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) {

View file

@ -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 (

View file

@ -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) {

View file

@ -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

View file

@ -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 {

View file

@ -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) {

View file

@ -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() {

View file

@ -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() {

View file

@ -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) {

View file

@ -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) {

View file

@ -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 (

View file

@ -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) {

View file

@ -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

View file

@ -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) {

View file

@ -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) {

View file

@ -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 (

View file

@ -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 (

View file

@ -5,5 +5,5 @@
var config = { var config = {
appRoot: "/", appRoot: "/",
disallowedTopics: ["docs", "static", "file", "app", "settings"] disallowedTopics: ["docs", "static", "file", "app", "settings"],
}; };

View file

@ -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 />
&nbsp;&nbsp;-H "Title: Unauthorized access detected" \<br /> &nbsp;&nbsp;-H "Title: Unauthorized access detected" \<br />
&nbsp;&nbsp;-H "Priority: urgent" \<br /> &nbsp;&nbsp;-H "Priority: urgent" \<br />
&nbsp;&nbsp;-H "Tags: warning,skull" \<br /> &nbsp;&nbsp;-H "Tags: warning,skull" \<br />
&nbsp;&nbsp;-d "Remote access to $(hostname) detected. Act right away." \<br/> &nbsp;&nbsp;-d "Remote access to $(hostname) detected. Act right away."
\<br />
&nbsp;&nbsp;<span class="ntfyUrl">ntfy.sh</span>/mytopic &nbsp;&nbsp;<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 &amp; forever free</h3> <h3 id="free-software" class="anchor">
100% open source &amp; 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>

View file

@ -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>

View file

@ -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+ */
} }

View file

@ -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 {

View file

@ -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 + "//";
}); });

View file

@ -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}`);

View file

@ -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("&")}`;
} }
} }

View file

@ -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;

View file

@ -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"
);
} }
} }

View file

@ -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);
} }

View file

@ -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
} }
} }

View file

@ -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) {

View file

@ -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();
} }
} }

View file

@ -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;

File diff suppressed because one or more lines are too long

View file

@ -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;
} };

View file

@ -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>

View file

@ -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;

View file

@ -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;

View file

@ -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>
); );
}; };

View file

@ -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;

View file

@ -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>
); );

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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";

View file

@ -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>
</> </>
); );

View file

@ -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>
); );

View file

@ -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();
}, []); }, []);
} };

View file

@ -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;

View file

@ -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;

View file

@ -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
}); });

View file

@ -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",
} },
} },
} },
} },
}, },
}); });

View file

@ -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 />);