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:
runs-on: ubuntu-latest
steps:
-
name: Install Go
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: '1.18.x'
-
name: Install node
go-version: "1.18.x"
- name: Install node
uses: actions/setup-node@v2
with:
node-version: '17'
-
name: Checkout code
node-version: "17"
- name: Checkout code
uses: actions/checkout@v2
-
name: Cache Go and npm modules
- name: Cache Go and npm modules
uses: actions/cache@v3
with:
path: |
@ -28,12 +24,9 @@ jobs:
web/node_modules
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
restore-keys: ${{ runner.os }}-ntfy-
-
name: Install dependencies
- name: Install dependencies
run: make build-deps-ubuntu
-
name: Build all the things
- name: Build all the things
run: make build
-
name: Print build results and checksums
- name: Print build results and checksums
run: make cli-build-results

View file

@ -7,11 +7,9 @@ jobs:
publish-docs:
runs-on: ubuntu-latest
steps:
-
name: Checkout ntfy code
- name: Checkout ntfy code
uses: actions/checkout@v3
-
name: Checkout docs pages code
- name: Checkout docs pages code
uses: actions/checkout@v3
with:
repository: binwiederhier/ntfy-docs.github.io
@ -19,14 +17,11 @@ jobs:
token: ${{secrets.NTFY_DOCS_PUSH_TOKEN}}
# Expires after 1 year, re-generate via
# User -> Settings -> Developer options -> Personal Access Tokens -> Fine Grained Token
-
name: Build docs
- name: Build 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/
-
name: Publish docs
- name: Publish docs
run: |
cd build/ntfy-docs.github.io
git config user.name "GitHub Actions Bot"

View file

@ -2,26 +2,22 @@ name: release
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
release:
runs-on: ubuntu-latest
steps:
-
name: Install Go
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: '1.18.x'
-
name: Install node
go-version: "1.18.x"
- name: Install node
uses: actions/setup-node@v2
with:
node-version: '17'
-
name: Checkout code
node-version: "17"
- name: Checkout code
uses: actions/checkout@v2
-
name: Cache Go and npm modules
- name: Cache Go and npm modules
uses: actions/cache@v3
with:
path: |
@ -31,20 +27,16 @@ jobs:
web/node_modules
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
restore-keys: ${{ runner.os }}-ntfy-
-
name: Docker login
- name: Docker login
uses: docker/login-action@v2
with:
username: ${{ github.repository_owner }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
-
name: Install dependencies
- name: Install dependencies
run: make build-deps-ubuntu
-
name: Build and publish
- name: Build and publish
run: make release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
name: Print build results and checksums
- name: Print build results and checksums
run: make cli-build-results

View file

@ -4,21 +4,17 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
-
name: Install Go
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: '1.18.x'
-
name: Install node
go-version: "1.18.x"
- name: Install node
uses: actions/setup-node@v2
with:
node-version: '17'
-
name: Checkout code
node-version: "17"
- name: Checkout code
uses: actions/checkout@v2
-
name: Cache Go and npm modules
- name: Cache Go and npm modules
uses: actions/cache@v3
with:
path: |
@ -28,21 +24,15 @@ jobs:
web/node_modules
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
restore-keys: ${{ runner.os }}-ntfy-
-
name: Install dependencies
- name: Install dependencies
run: make build-deps-ubuntu
-
name: Build docs (required for tests)
- name: Build docs (required for tests)
run: make docs
-
name: Build web app (required for tests)
- name: Build web app (required for tests)
run: make web
-
name: Run tests, formatting, vetting and linting
- name: Run tests, formatting, vetting and linting
run: make check
-
name: Run coverage
- name: Run coverage
run: make coverage
-
name: Upload coverage to codecov.io
- name: Upload coverage to codecov.io
run: make coverage-upload

View file

@ -3,12 +3,11 @@ before:
- go mod download
- go mod tidy
builds:
-
id: ntfy_linux_amd64
- id: ntfy_linux_amd64
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
tags: [sqlite_omit_load_extension,osusergo,netgo]
tags: [sqlite_omit_load_extension, osusergo, netgo]
ldflags:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
@ -16,46 +15,42 @@ builds:
hooks:
post:
- upx "{{ .Path }}" # apt install upx
-
id: ntfy_linux_armv6
- id: ntfy_linux_armv6
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
tags: [sqlite_omit_load_extension,osusergo,netgo]
tags: [sqlite_omit_load_extension, osusergo, netgo]
ldflags:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [arm]
goarm: [6]
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
-
id: ntfy_linux_armv7
- id: ntfy_linux_armv7
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
tags: [sqlite_omit_load_extension,osusergo,netgo]
tags: [sqlite_omit_load_extension, osusergo, netgo]
ldflags:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [arm]
goarm: [7]
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
-
id: ntfy_linux_arm64
- id: ntfy_linux_arm64
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
- CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu
tags: [sqlite_omit_load_extension,osusergo,netgo]
tags: [sqlite_omit_load_extension, osusergo, netgo]
ldflags:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [arm64]
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
-
id: ntfy_windows_amd64
- id: ntfy_windows_amd64
binary: ntfy
env:
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
@ -65,8 +60,7 @@ builds:
goos: [windows]
goarch: [amd64]
# No "upx" for Windows to hopefully avoid Virus warnings
-
id: ntfy_darwin_all
- id: ntfy_darwin_all
binary: ntfy
env:
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
@ -76,8 +70,7 @@ builds:
goos: [darwin]
goarch: [amd64, arm64] # will be combined to "universal binary" (see below)
nfpms:
-
package_name: ntfy
- package_name: ntfy
homepage: https://heckel.io/ntfy
maintainer: Philipp C. Heckel <philipp.heckel@gmail.com>
description: Simple pub-sub notification service
@ -111,8 +104,7 @@ nfpms:
preremove: "scripts/prerm.sh"
postremove: "scripts/postrm.sh"
archives:
-
id: ntfy_linux
- id: ntfy_linux
builds:
- ntfy_linux_amd64
- ntfy_linux_armv6
@ -128,8 +120,7 @@ archives:
- client/ntfy-client.service
replacements:
amd64: x86_64
-
id: ntfy_windows
- id: ntfy_windows
builds:
- ntfy_windows_amd64
format: zip
@ -140,8 +131,7 @@ archives:
- client/client.yml
replacements:
amd64: x86_64
-
id: ntfy_darwin
- id: ntfy_darwin
builds:
- ntfy_darwin_all
wrap_in_directory: true
@ -152,20 +142,19 @@ archives:
replacements:
darwin: macOS
universal_binaries:
-
id: ntfy_darwin_all
- id: ntfy_darwin_all
replace: true
name_template: ntfy
checksum:
name_template: 'checksums.txt'
name_template: "checksums.txt"
snapshot:
name_template: "{{ .Tag }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- "^docs:"
- "^test:"
dockers:
- image_templates:
- &amd64_image "binwiederhier/ntfy:{{ .Tag }}-amd64"

View file

@ -1,8 +1,9 @@
repos:
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v2.7.1"
rev: "v3.0.0-alpha.4"
hooks:
- id: prettier
exclude_types: [markdown]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
@ -12,14 +13,13 @@ repos:
stages: ["commit"]
- repo: https://github.com/Bahjat/pre-commit-golang
rev: v1.0.2
rev: v1.0.3
hooks:
- id: go-fmt-import
- id: go-vet
- id: go-lint
- id: go-unit-tests
stages: ["push"]
- id: gofumpt # requires github.com/mvdan/gofumpt
- id: golangci-lint # requires github.com/golangci/golangci-lint
args: [--config=.github/linters/.golangci.yml] # optional
- 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
[FAQ]: https://www.contributor-covenant.org/faq
[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
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

View file

@ -4,9 +4,10 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"golang.org/x/crypto/bcrypt"
"strings"
)
const (

View file

@ -1,12 +1,13 @@
package auth_test
import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/auth"
"path/filepath"
"strings"
"testing"
"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

View file

@ -7,13 +7,14 @@ import (
"encoding/json"
"errors"
"fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"io"
"net/http"
"strings"
"sync"
"time"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
)
// Event type constants

View file

@ -2,11 +2,12 @@ package client_test
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/client"
"heckel.io/ntfy/test"
"testing"
"time"
)
func TestClient_Publish_Subscribe(t *testing.T) {

View file

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

View file

@ -1,11 +1,12 @@
package client_test
import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/client"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/client"
)
func TestConfig_Load(t *testing.T) {

View file

@ -2,10 +2,11 @@ package client
import (
"fmt"
"heckel.io/ntfy/util"
"net/http"
"strings"
"time"
"heckel.io/ntfy/util"
)
// RequestOption is a generic request option that can be added to Client calls

View file

@ -5,6 +5,7 @@ package cmd
import (
"errors"
"fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"

View file

@ -2,11 +2,12 @@ package cmd
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/server"
"heckel.io/ntfy/test"
"testing"
)
func TestCLI_Access_Show(t *testing.T) {

View file

@ -2,10 +2,11 @@
package cmd
import (
"os"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/log"
"os"
)
const (

View file

@ -3,11 +3,12 @@ package cmd
import (
"bytes"
"encoding/json"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/client"
"os"
"strings"
"testing"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/client"
)
// This only contains helpers so far

View file

@ -2,11 +2,12 @@ package cmd
import (
"fmt"
"os"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"gopkg.in/yaml.v2"
"heckel.io/ntfy/util"
"os"
)
// initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks

View file

@ -1,10 +1,11 @@
package cmd
import (
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestNewYamlSourceFromFile(t *testing.T) {

View file

@ -3,16 +3,17 @@ package cmd
import (
"errors"
"fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/client"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/client"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
)
func init() {

View file

@ -2,14 +2,15 @@ package cmd
import (
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/test"
"heckel.io/ntfy/util"
"os"
"os/exec"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/test"
"heckel.io/ntfy/util"
)
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {

View file

@ -3,16 +3,17 @@ package cmd
import (
"errors"
"fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/client"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"os"
"os/exec"
"os/user"
"path/filepath"
"sort"
"strings"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/client"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
)
func init() {

View file

@ -1,12 +1,13 @@
package cmd
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/server"
"heckel.io/ntfy/test"
"path/filepath"
"testing"
)
func TestCLI_User_Add(t *testing.T) {

View file

@ -14,4 +14,3 @@ services:
ports:
- 80:80
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]
--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
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`):
![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>
<figcaption>Sending push notifications to your Android phone</figcaption>
</figure>

View file

@ -19,22 +19,25 @@
}
.admonition {
font-size: .74rem !important;
font-size: 0.74rem !important;
}
article {
padding-bottom: 50px;
}
figure img, figure video {
figure img,
figure video {
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);
}
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);
}
@ -50,7 +53,7 @@ figure video {
}
.remove-md-box td {
padding: 0 10px
padding: 0 10px;
}
/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */
@ -75,7 +78,7 @@ figure video {
opacity: 0;
visibility: hidden;
position: fixed;
left:0;
left: 0;
right: 0;
top: 0;
bottom: 0;
@ -87,7 +90,7 @@ figure video {
}
.lightbox.show {
background-color: rgba(0,0,0, 0.75);
background-color: rgba(0, 0, 0, 0.75);
opacity: 1;
visibility: visible;
z-index: 1000;
@ -111,7 +114,7 @@ figure video {
.lightbox .close-lightbox::after,
.lightbox .close-lightbox::before {
content: '';
content: "";
width: 3px;
height: 20px;
background-color: #ddd;

View file

@ -1,51 +1,59 @@
// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
const savedCodeTab = localStorage.getItem('savedTab')
const codeTabs = document.querySelectorAll(".tabbed-set > input")
const savedCodeTab = localStorage.getItem("savedTab");
const codeTabs = document.querySelectorAll(".tabbed-set > input");
for (const tab of codeTabs) {
tab.addEventListener("click", () => {
const current = document.querySelector(`label[for=${tab.id}]`)
const pos = current.getBoundingClientRect().top
const labelContent = current.innerHTML
const labels = document.querySelectorAll('.tabbed-set > label, .tabbed-alternate > .tabbed-labels > label')
const current = document.querySelector(`label[for=${tab.id}]`);
const pos = current.getBoundingClientRect().top;
const labelContent = current.innerHTML;
const labels = document.querySelectorAll(
".tabbed-set > label, .tabbed-alternate > .tabbed-labels > label",
);
for (const label of labels) {
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
const delta = (current.getBoundingClientRect().top) - pos
window.scrollBy(0, delta)
const delta = current.getBoundingClientRect().top - pos;
window.scrollBy(0, delta);
// Save
localStorage.setItem('savedTab', labelContent)
})
localStorage.setItem("savedTab", labelContent);
});
// Select saved tab
const current = document.querySelector(`label[for=${tab.id}]`)
const labelContent = current.innerHTML
const current = document.querySelector(`label[for=${tab.id}]`);
const labelContent = current.innerHTML;
if (savedCodeTab === labelContent) {
tab.checked = true
tab.checked = true;
}
}
// Lightbox for screenshot
const lightbox = document.createElement('div');
lightbox.classList.add('lightbox');
const lightbox = document.createElement("div");
lightbox.classList.add("lightbox");
document.body.appendChild(lightbox);
const showScreenshotOverlay = (e, el, group, index) => {
lightbox.classList.add('show');
document.addEventListener('keydown', nextScreenshotKeyboardListener);
lightbox.classList.add("show");
document.addEventListener("keydown", nextScreenshotKeyboardListener);
return showScreenshot(e, group, index);
};
const showScreenshot = (e, group, index) => {
const actualIndex = resolveScreenshotIndex(group, index);
lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[group][actualIndex].innerHTML;
lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e, group, actualIndex+1); };
lightbox.innerHTML =
'<div class="close-lightbox"></div>' +
screenshots[group][actualIndex].innerHTML;
lightbox.querySelector("img").onclick = (e) => {
return showScreenshot(e, group, actualIndex + 1);
};
currentScreenshotGroup = group;
currentScreenshotIndex = actualIndex;
e.stopPropagation();
@ -53,11 +61,11 @@ const showScreenshot = (e, group, index) => {
};
const nextScreenshot = (e) => {
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex+1);
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex + 1);
};
const previousScreenshot = (e) => {
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex-1);
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex - 1);
};
const resolveScreenshotIndex = (group, index) => {
@ -70,8 +78,8 @@ const resolveScreenshotIndex = (group, index) => {
};
const hideScreenshotOverlay = (e) => {
lightbox.classList.remove('show');
document.removeEventListener('keydown', nextScreenshotKeyboardListener);
lightbox.classList.remove("show");
document.removeEventListener("keydown", nextScreenshotKeyboardListener);
};
const nextScreenshotKeyboardListener = (e) => {
@ -85,14 +93,16 @@ const nextScreenshotKeyboardListener = (e) => {
}
};
let currentScreenshotGroup = '';
let currentScreenshotGroup = "";
let currentScreenshotIndex = 0;
let screenshots = {};
Array.from(document.getElementsByClassName('screenshots')).forEach((sg) => {
Array.from(document.getElementsByClassName("screenshots")).forEach((sg) => {
const group = sg.id;
screenshots[group] = [...sg.querySelectorAll('a')];
screenshots[group] = [...sg.querySelectorAll("a")];
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,56 +1,64 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<head>
<meta charset="UTF-8" />
<title>ntfy.sh: EventSource Example</title>
<meta name="robots" content="noindex, nofollow" />
<style>
body { font-size: 1.2em; line-height: 130%; }
#events { font-family: monospace; }
body {
font-size: 1.2em;
line-height: 130%;
}
#events {
font-family: monospace;
}
</style>
</head>
<body>
<h1>ntfy.sh: EventSource Example</h1>
<p>
This is an example showing how to use <a href="https://ntfy.sh">ntfy.sh</a> with
<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>
<button id="publishButton">Send test notification</button>
<p><b>Log:</b></p>
<div id="events"></div>
</head>
<body>
<h1>ntfy.sh: EventSource Example</h1>
<p>
This is an example showing how to use
<a href="https://ntfy.sh">ntfy.sh</a> with
<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>
<button id="publishButton">Send test notification</button>
<p><b>Log:</b></p>
<div id="events"></div>
<script type="text/javascript">
<script type="text/javascript">
const publishURL = `https://ntfy.sh/example`;
const subscribeURL = `https://ntfy.sh/example/sse`;
const events = document.getElementById('events');
const events = document.getElementById("events");
const eventSource = new EventSource(subscribeURL);
// Publish button
document.getElementById("publishButton").onclick = () => {
fetch(publishURL, {
method: 'POST', // works with PUT as well, though that sends an OPTIONS request too!
body: `It is ${new Date().toString()}. This is a test.`
})
method: "POST", // works with PUT as well, though that sends an OPTIONS request too!
body: `It is ${new Date().toString()}. This is a test.`,
});
};
// Incoming events
eventSource.onopen = () => {
let event = document.createElement('div');
let event = document.createElement("div");
event.innerHTML = `EventSource connected to ${subscribeURL}`;
events.appendChild(event);
};
eventSource.onerror = (e) => {
let event = document.createElement('div');
let event = document.createElement("div");
event.innerHTML = `EventSource error: Failed to connect to ${subscribeURL}`;
events.appendChild(event);
};
eventSource.onmessage = (e) => {
let event = document.createElement('div');
let event = document.createElement("div");
event.innerHTML = e.data;
events.appendChild(event);
};
</script>
</body>
</script>
</body>
</html>

View file

@ -2,10 +2,11 @@ package main
import (
"fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/cmd"
"os"
"runtime"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/cmd"
)
var (

View file

@ -71,18 +71,18 @@ plugins:
minify_html: true
nav:
- "Getting started": index.md
- "Publishing":
- "Getting started": index.md
- "Publishing":
- "Sending messages": publish.md
- "Subscribing":
- "Subscribing":
- "From your phone": subscribe/phone.md
- "From the Web UI": subscribe/web.md
- "From the CLI": subscribe/cli.md
- "Using the API": subscribe/api.md
- "Self-hosting":
- "Self-hosting":
- "Installation": install.md
- "Configuration": config.md
- "Other things":
- "Other things":
- "FAQs": faq.md
- "Examples": examples.md
- "Integrations + projects": integrations.md
@ -92,5 +92,3 @@ nav:
- "Deprecation notices": deprecations.md
- "Development": develop.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
rmdir /etc/ntfy || true
fi

View file

@ -4,10 +4,11 @@ import (
"encoding/json"
"errors"
"fmt"
"heckel.io/ntfy/util"
"regexp"
"strings"
"unicode/utf8"
"heckel.io/ntfy/util"
)
const (

View file

@ -1,8 +1,9 @@
package server
import (
"github.com/stretchr/testify/require"
"testing"
"github.com/stretchr/testify/require"
)
func TestParseActions(t *testing.T) {

View file

@ -1,9 +1,10 @@
package server_test
import (
"testing"
"github.com/stretchr/testify/assert"
"heckel.io/ntfy/server"
"testing"
)
func TestConfig_New(t *testing.T) {

View file

@ -3,13 +3,14 @@ package server
import (
"errors"
"fmt"
"heckel.io/ntfy/util"
"io"
"os"
"path/filepath"
"regexp"
"sync"
"time"
"heckel.io/ntfy/util"
)
var (

View file

@ -3,12 +3,13 @@ package server
import (
"bytes"
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/util"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/util"
)
var (

File diff suppressed because one or more lines are too long

View file

@ -4,14 +4,15 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/messaging"
"fmt"
"google.golang.org/api/option"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"strings"
)
const (

View file

@ -4,11 +4,12 @@ import (
"bytes"
"encoding/json"
"fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"io"
"net/http"
"strings"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
)
// 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)
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)
require.Equal(t, 301, rr.Code)

View file

@ -4,14 +4,15 @@ import (
_ "embed" // required by go:embed
"encoding/json"
"fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"mime"
"net"
"net/smtp"
"strings"
"sync"
"time"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
)
type mailer interface {

View file

@ -1,8 +1,9 @@
package server
import (
"github.com/stretchr/testify/require"
"testing"
"github.com/stretchr/testify/require"
)
func TestFormatMail_Basic(t *testing.T) {

View file

@ -4,8 +4,6 @@ import (
"bytes"
"errors"
"fmt"
"github.com/emersion/go-smtp"
"heckel.io/ntfy/log"
"io"
"mime"
"mime/multipart"
@ -15,6 +13,9 @@ import (
"net/mail"
"strings"
"sync"
"github.com/emersion/go-smtp"
"heckel.io/ntfy/log"
)
var (

View file

@ -1,12 +1,13 @@
package server
import (
"github.com/emersion/go-smtp"
"github.com/stretchr/testify/require"
"net"
"net/http"
"strings"
"testing"
"github.com/emersion/go-smtp"
"github.com/stretchr/testify/require"
)
func TestSmtpBackend_Multipart(t *testing.T) {

View file

@ -1,9 +1,10 @@
package server
import (
"heckel.io/ntfy/log"
"math/rand"
"sync"
"heckel.io/ntfy/log"
)
// topic represents a channel to which subscribers can subscribe, and publishers

View file

@ -2,11 +2,12 @@ package server
import (
"fmt"
"github.com/emersion/go-smtp"
"heckel.io/ntfy/util"
"net/http"
"strings"
"unicode/utf8"
"github.com/emersion/go-smtp"
"heckel.io/ntfy/util"
)
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {

View file

@ -3,11 +3,12 @@ package server
import (
"bytes"
"fmt"
"github.com/stretchr/testify/require"
"math/rand"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestReadBoolParam(t *testing.T) {

View file

@ -2,12 +2,13 @@ package test
import (
"fmt"
"heckel.io/ntfy/server"
"math/rand"
"net/http"
"path/filepath"
"testing"
"time"
"heckel.io/ntfy/server"
)
func init() {

View file

@ -2,13 +2,14 @@ package main
import (
"context"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/messaging"
"flag"
"fmt"
"google.golang.org/api/option"
"os"
"strings"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/messaging"
"google.golang.org/api/option"
)
func main() {

View file

@ -1,12 +1,13 @@
package util_test
import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/util"
"math/rand"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/util"
)
func TestBatchingQueue_InfTimeout(t *testing.T) {

View file

@ -2,9 +2,10 @@ package util
import (
"crypto/rand"
"github.com/stretchr/testify/require"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestSniffWriter_WriteHTML(t *testing.T) {

View file

@ -2,11 +2,12 @@ package util
import (
"embed"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var (

View file

@ -2,11 +2,12 @@ package util
import (
"compress/gzip"
"github.com/stretchr/testify/require"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestGzipHandler(t *testing.T) {

View file

@ -2,10 +2,11 @@ package util
import (
"errors"
"golang.org/x/time/rate"
"io"
"sync"
"time"
"golang.org/x/time/rate"
)
// 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 (
"bytes"
"github.com/stretchr/testify/require"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestFixedLimiter_Add(t *testing.T) {

View file

@ -1,10 +1,11 @@
package util
import (
"github.com/stretchr/testify/require"
"io"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestPeak_LimitReached(t *testing.T) {

View file

@ -2,11 +2,12 @@ package util
import (
"errors"
"github.com/olebedev/when"
"regexp"
"strconv"
"strings"
"time"
"github.com/olebedev/when"
)
var (

View file

@ -1,9 +1,10 @@
package util
import (
"github.com/stretchr/testify/require"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var (

View file

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

View file

@ -1,41 +1,49 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<head>
<meta charset="UTF-8" />
<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 -->
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="HandheldFriendly" content="true">
<meta
name="viewport"
content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"
/>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="HandheldFriendly" content="true" />
<!-- Mobile browsers, background color -->
<meta name="theme-color" content="#317f6f">
<meta name="msapplication-navbutton-color" content="#317f6f">
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f">
<meta name="theme-color" content="#317f6f" />
<meta name="msapplication-navbutton-color" content="#317f6f" />
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
<!-- Favicon, see favicon.io -->
<link rel="icon" type="image/png" href="static/img/favicon.png">
<link rel="icon" type="image/png" href="static/img/favicon.png" />
<!-- Previews in Google, Slack, WhatsApp, etc. -->
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="ntfy.sh" />
<meta 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: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:url" content="https://ntfy.sh" />
<!-- Fonts -->
<link rel="stylesheet" href="static/css/fonts.css" type="text/css">
</head>
<body>
<nav id="header">
<link rel="stylesheet" href="static/css/fonts.css" type="text/css" />
</head>
<body>
<nav id="header">
<div id="headerBox">
<img id="logo" src="static/img/ntfy.png" alt="logo"/>
<img id="logo" src="static/img/ntfy.png" alt="logo" />
<div id="name">ntfy</div>
<ol>
<li><a href="app">Web app</a></li>
@ -45,138 +53,214 @@
<li><a href="https://github.com/binwiederhier/ntfy">GitHub</a></li>
</ol>
</div>
</nav>
<div id="main">
</nav>
<div id="main">
<h1>Send push notifications to your phone or desktop via PUT/POST</h1>
<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.
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.
<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. 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>
<div id="screenshots">
<a href="static/img/screenshot-curl.png"><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>
<a href="static/img/screenshot-curl.png"
><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">
<a href="static/img/screenshot-phone-main.jpg"><img src="static/img/screenshot-phone-main.jpg"/></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>
<a href="static/img/screenshot-phone-main.jpg"
><img src="static/img/screenshot-phone-main.jpg"
/></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>
</div>
<h2 id="publish" class="anchor">Publishing messages</h2>
<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.
Because there is no sign-up, <b>the topic is essentially a password</b>, so pick something that's not easily guessable.
<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. Because there is no sign-up,
<b>the topic is essentially a password</b>, so pick something that's not
easily guessable.
</p>
<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>
<code>
curl -d "Backup successful 😀" <span class="ntfyUrl">ntfy.sh</span>/mytopic
curl -d "Backup successful 😀"
<span class="ntfyUrl">ntfy.sh</span>/mytopic
</code>
<p class="smallMarginBottom">
There are <a href="docs/publish/">more features</a> related to publishing messages: You can set a
<a href="docs/publish/#message-priority">notification priority</a>, a <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:
There are <a href="docs/publish/">more features</a> related to
publishing messages: You can set a
<a href="docs/publish/#message-priority">notification priority</a>, a
<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>
<code>
curl \<br/>
&nbsp;&nbsp;-H "Title: Unauthorized access detected" \<br/>
&nbsp;&nbsp;-H "Priority: urgent" \<br/>
&nbsp;&nbsp;-H "Tags: warning,skull" \<br/>
&nbsp;&nbsp;-d "Remote access to $(hostname) detected. Act right away." \<br/>
curl \<br />
&nbsp;&nbsp;-H "Title: Unauthorized access detected" \<br />
&nbsp;&nbsp;-H "Priority: urgent" \<br />
&nbsp;&nbsp;-H "Tags: warning,skull" \<br />
&nbsp;&nbsp;-d "Remote access to $(hostname) detected. Act right away."
\<br />
&nbsp;&nbsp;<span class="ntfyUrl">ntfy.sh</span>/mytopic
</code>
<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>
<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>
</figure>
<h2 id="subscribe" class="anchor">Subscribe to a topic</h2>
<p>
You can create and subscribe to a topic either <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>.
You can create and subscribe to a topic either
<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>
<h3 id="subscribe-phone" class="anchor">Subscribe from your phone</h3>
<p>
Simply get the app and start <a href="docs/publish/">publishing messages</a>. To learn more about the app,
<a href="docs/subscribe/phone/">check out the documentation</a>.
Simply get the app and start
<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>
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"></a>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="static/img/badge-appstore.png"></a>
</p>
<p>
Here's a video showing the app in action:
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"
><img src="static/img/badge-googleplay.png"
/></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"
><img src="static/img/badge-fdroid.png"
/></a>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"
><img src="static/img/badge-appstore.png"
/></a>
</p>
<p>Here's a video showing the app in action:</p>
<figure>
<video 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>
<video
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>
<h3 id="subscribe-web" class="anchor">Subscribe via web app</h3>
<p>
Subscribe to topics in the <a href="app">web app</a> and receive messages as <b>desktop notification</b>.
It is available at <b><a href="app"><span class="ntfyUrl">ntfy.sh</span>/app</a></b>.
Subscribe to topics in the <a href="app">web app</a> and receive
messages as <b>desktop notification</b>. It is available at
<b
><a href="app"><span class="ntfyUrl">ntfy.sh</span>/app</a></b
>.
</p>
<figure>
<a href="app"><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>
<a href="app"
><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>
<h3 id="subscribe-api" class="anchor">Subscribe using the API</h3>
<p>
There's a super simple API that you can use to integrate your own app. You can consume
a <a href="docs/subscribe/api/#subscribe-as-json-stream">JSON stream</a>,
an <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>.
There's a super simple API that you can use to integrate your own app.
You can consume a
<a href="docs/subscribe/api/#subscribe-as-json-stream">JSON stream</a>,
an
<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 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>
<code>
$ curl -s <span class="ntfyUrl">ntfy.sh</span>/mytopic/json<br/>
{"id":"SLiKI64DOt","time":1635528757,"event":"open","topic":"mytopic"}<br/>
{"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Hi!"}<br/>
{"id":"DGUDShMCsc","time":1635528787,"event":"keepalive","topic":"mytopic"}<br/>
$ curl -s <span class="ntfyUrl">ntfy.sh</span>/mytopic/json<br />
{"id":"SLiKI64DOt","time":1635528757,"event":"open","topic":"mytopic"}<br />
{"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Hi!"}<br />
{"id":"DGUDShMCsc","time":1635528787,"event":"keepalive","topic":"mytopic"}<br />
...
</code>
<p>
Here's a short video demonstrating it in action:
</p>
<p>Here's a short video demonstrating it in action:</p>
<figure>
<video 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>
<video
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>
<h3 id="docs" class="anchor">Check out the docs!</h3>
<p>
ntfy has so many more features and you can learn about all of them <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).
ntfy has so many more features and you can learn about all of them
<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>
<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>
</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>
I love free software, and I'm doing this because it's fun. I have no bad intentions, and I will
never monetize or sell your information. This service will always stay
<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>.
I love free software, and I'm doing this because it's fun. I have no bad
intentions, and I will never monetize or sell your information. This
service will always stay
<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>
<center id="ironicCenterTagDontFreakOut"><i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i></center>
</div>
<div id="lightbox" class="lightbox"></div>
<script src="static/js/home.js"></script>
</body>
<center id="ironicCenterTagDontFreakOut">
<i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i>
</center>
</div>
<div id="lightbox" class="lightbox"></div>
<script src="static/js/home.js"></script>
</body>
</html>

View file

@ -1,28 +1,38 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<head>
<meta charset="UTF-8" />
<title>ntfy web</title>
<!-- Mobile view -->
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="HandheldFriendly" content="true">
<meta
name="viewport"
content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"
/>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="HandheldFriendly" content="true" />
<!-- Mobile browsers, background color -->
<meta name="theme-color" content="#317f6f">
<meta name="msapplication-navbutton-color" content="#317f6f">
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f">
<meta name="theme-color" content="#317f6f" />
<meta name="msapplication-navbutton-color" content="#317f6f" />
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
<!-- Favicon, see favicon.io -->
<link rel="icon" type="image/png" href="%PUBLIC_URL%/static/img/favicon.png">
<link
rel="icon"
type="image/png"
href="%PUBLIC_URL%/static/img/favicon.png"
/>
<!-- Previews in Google, Slack, WhatsApp, etc. -->
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="ntfy web" />
<meta property="og:title" content="ntfy web" />
<meta property="og:description" content="ntfy lets you send push notifications via scripts from any computer or phone, 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:url" content="https://ntfy.sh" />
@ -30,14 +40,20 @@
<meta name="robots" content="noindex, nofollow" />
<!-- Fonts -->
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css">
</head>
<body>
<link
rel="stylesheet"
href="%PUBLIC_URL%/static/css/fonts.css"
type="text/css"
/>
</head>
<body>
<noscript>
ntfy web requires JavaScript, but you can also use the <a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a>
or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to subscribe.
ntfy web requires JavaScript, but you can also use the
<a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a> or
<a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to
subscribe.
</noscript>
<div id="root"></div>
<script src="%PUBLIC_URL%/config.js"></script>
</body>
</body>
</html>

View file

@ -2,40 +2,40 @@
/* roboto-300 - latin */
@font-face {
font-family: 'Roboto';
font-family: "Roboto";
font-style: normal;
font-weight: 300;
src: local(''),
url('../fonts/roboto-v29-latin-300.woff2') format('woff2'), /* 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+ */
src: local(""), url("../fonts/roboto-v29-latin-300.woff2") format("woff2"),
/* 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+ */
}
/* roboto-regular - latin */
@font-face {
font-family: 'Roboto';
font-family: "Roboto";
font-style: normal;
font-weight: 400;
src: local(''),
url('../fonts/roboto-v29-latin-regular.woff2') format('woff2'), /* 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+ */
src: local(""), url("../fonts/roboto-v29-latin-regular.woff2") format("woff2"),
/* 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+ */
}
/* roboto-500 - latin */
@font-face {
font-family: 'Roboto';
font-family: "Roboto";
font-style: normal;
font-weight: 500;
src: local(''),
url('../fonts/roboto-v29-latin-500.woff2') format('woff2'), /* 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+ */
src: local(""), url("../fonts/roboto-v29-latin-500.woff2") format("woff2"),
/* 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+ */
}
/* roboto-700 - latin */
@font-face {
font-family: 'Roboto';
font-family: "Roboto";
font-style: normal;
font-weight: 700;
src: local(''),
url('../fonts/roboto-v29-latin-700.woff2') format('woff2'), /* 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+ */
src: local(""), url("../fonts/roboto-v29-latin-700.woff2") format("woff2"),
/* 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+ */
}

View file

@ -1,7 +1,8 @@
/* general styling */
html, body {
font-family: 'Roboto', sans-serif;
html,
body {
font-family: "Roboto", sans-serif;
font-weight: 400;
font-size: 1.1em;
color: #444;
@ -15,7 +16,8 @@ html {
overflow-y: scroll;
}
a, a:visited {
a,
a:visited {
color: #338574;
}
@ -124,7 +126,8 @@ figure {
text-align: center;
}
figure img, figure video {
figure img,
figure video {
filter: drop-shadow(3px 3px 3px #ccc);
border-radius: 7px;
max-width: 100%;
@ -164,7 +167,7 @@ figcaption {
opacity: 0;
visibility: hidden;
position: fixed;
left:0;
left: 0;
right: 0;
top: 0;
bottom: 0;
@ -176,7 +179,7 @@ figcaption {
}
.lightbox.show {
background-color: rgba(0,0,0, 0.75);
background-color: rgba(0, 0, 0, 0.75);
opacity: 1;
visibility: visible;
z-index: 1000;
@ -200,7 +203,7 @@ figcaption {
.lightbox .close-lightbox::after,
.lightbox .close-lightbox::before {
content: '';
content: "";
width: 3px;
height: 20px;
background-color: #ddd;
@ -256,7 +259,8 @@ figcaption {
font-weight: 400;
}
#header ol li a, nav ol li a:visited {
#header ol li a,
nav ol li a:visited {
color: white;
text-decoration: none;
}
@ -271,7 +275,6 @@ li {
font-size: 0.9em;
}
/* Hide top menu SMALL SCREEN */
@media only screen and (max-width: 780px) {
#header ol {

View file

@ -1,35 +1,37 @@
/* All the things */
let currentUrl = window.location.hostname;
if (window.location.port) {
currentUrl += ':' + window.location.port
currentUrl += ":" + window.location.port;
}
/* Screenshots */
const lightbox = document.getElementById("lightbox");
const showScreenshotOverlay = (e, el, index) => {
lightbox.classList.add('show');
document.addEventListener('keydown', nextScreenshotKeyboardListener);
lightbox.classList.add("show");
document.addEventListener("keydown", nextScreenshotKeyboardListener);
return showScreenshot(e, index);
};
const showScreenshot = (e, index) => {
const actualIndex = resolveScreenshotIndex(index);
lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[actualIndex].innerHTML;
lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e,actualIndex+1); };
lightbox.innerHTML =
'<div class="close-lightbox"></div>' + screenshots[actualIndex].innerHTML;
lightbox.querySelector("img").onclick = (e) => {
return showScreenshot(e, actualIndex + 1);
};
currentScreenshotIndex = actualIndex;
e.stopPropagation();
return false;
};
const nextScreenshot = (e) => {
return showScreenshot(e, currentScreenshotIndex+1);
return showScreenshot(e, currentScreenshotIndex + 1);
};
const previousScreenshot = (e) => {
return showScreenshot(e, currentScreenshotIndex-1);
return showScreenshot(e, currentScreenshotIndex - 1);
};
const resolveScreenshotIndex = (index) => {
@ -42,8 +44,8 @@ const resolveScreenshotIndex = (index) => {
};
const hideScreenshotOverlay = (e) => {
lightbox.classList.remove('show');
document.removeEventListener('keydown', nextScreenshotKeyboardListener);
lightbox.classList.remove("show");
document.removeEventListener("keydown", nextScreenshotKeyboardListener);
};
const nextScreenshotKeyboardListener = (e) => {
@ -60,25 +62,27 @@ const nextScreenshotKeyboardListener = (e) => {
let currentScreenshotIndex = 0;
const screenshots = [...document.querySelectorAll("#screenshots a")];
screenshots.forEach((el, index) => {
el.onclick = (e) => { return showScreenshotOverlay(e, el, index); };
el.onclick = (e) => {
return showScreenshotOverlay(e, el, index);
};
});
lightbox.onclick = hideScreenshotOverlay;
// Add anchor links
document.querySelectorAll('.anchor').forEach((el) => {
if (el.hasAttribute('id')) {
const id = el.getAttribute('id');
const anchor = document.createElement('a');
document.querySelectorAll(".anchor").forEach((el) => {
if (el.hasAttribute("id")) {
const id = el.getAttribute("id");
const anchor = document.createElement("a");
anchor.innerHTML = `<a href="#${id}" class="anchorLink">#</a>`;
el.appendChild(anchor);
}
});
// Change ntfy.sh url and protocol to match self-hosted one
document.querySelectorAll('.ntfyUrl').forEach((el) => {
document.querySelectorAll(".ntfyUrl").forEach((el) => {
el.innerHTML = currentUrl;
});
document.querySelectorAll('.ntfyProtocol').forEach((el) => {
document.querySelectorAll(".ntfyProtocol").forEach((el) => {
el.innerHTML = window.location.protocol + "//";
});

View file

@ -6,7 +6,7 @@ import {
topicUrlAuth,
topicUrlJsonPoll,
topicUrlJsonPollWithSince,
userStatsUrl
userStatsUrl,
} from "./utils";
import userManager from "./UserManager";
@ -14,7 +14,7 @@ class Api {
async poll(baseUrl, topic, since) {
const user = await userManager.get(baseUrl);
const shortUrl = topicShortUrl(baseUrl, topic);
const url = (since)
const url = since
? topicUrlJsonPollWithSince(baseUrl, topic, since)
: topicUrlJsonPoll(baseUrl, topic);
const messages = [];
@ -34,12 +34,12 @@ class Api {
const body = {
topic: topic,
message: message,
...options
...options,
};
const response = await fetch(baseUrl, {
method: 'PUT',
method: "PUT",
body: JSON.stringify(body),
headers: maybeWithBasicAuth(headers, user)
headers: maybeWithBasicAuth(headers, user),
});
if (response.status < 200 || response.status > 299) {
throw new Error(`Unexpected response: ${response.status}`);
@ -72,13 +72,19 @@ class Api {
xhr.setRequestHeader(key, value);
}
xhr.upload.addEventListener("progress", onProgress);
xhr.addEventListener('readystatechange', (ev) => {
xhr.addEventListener("readystatechange", (ev) => {
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);
} else if (xhr.readyState === 4) {
// 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;
try {
const error = JSON.parse(xhr.responseText);
@ -91,13 +97,13 @@ class Api {
xhr.abort();
reject(errorText ?? "An error occurred");
}
})
});
xhr.send(body);
});
send.abort = () => {
console.log(`[Api] Publish aborted by user`);
xhr.abort();
}
};
return send;
}
@ -105,13 +111,14 @@ class Api {
const url = topicUrlAuth(baseUrl, topic);
console.log(`[Api] Checking auth for ${url}`);
const response = await fetch(url, {
headers: maybeWithBasicAuth({}, user)
headers: maybeWithBasicAuth({}, user),
});
if (response.status >= 200 && response.status <= 299) {
return true;
} else if (!user && response.status === 404) {
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;
}
throw new Error(`Unexpected server response ${response.status}`);

View file

@ -1,4 +1,4 @@
import {basicAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils";
import { basicAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils";
const retryBackoffSeconds = [5, 10, 15, 20, 30];
@ -9,7 +9,16 @@ const retryBackoffSeconds = [5, 10, 15, 20, 30];
* Incoming messages and state changes are forwarded via listeners.
*/
class Connection {
constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) {
constructor(
connectionId,
subscriptionId,
baseUrl,
topic,
user,
since,
onNotification,
onStateChanged,
) {
this.connectionId = connectionId;
this.subscriptionId = subscriptionId;
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.
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.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.onStateChanged(this.subscriptionId, ConnectionState.Connected);
}
};
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 {
const data = JSON.parse(event.data);
if (data.event === 'open') {
if (data.event === "open") {
return;
}
const relevantAndValid =
data.event === 'message' &&
'id' in data &&
'time' in data &&
'message' in data;
data.event === "message" &&
"id" in data &&
"time" in data &&
"message" in data;
if (!relevantAndValid) {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`,
);
return;
}
this.since = data.id;
this.onNotification(this.subscriptionId, data);
} catch (e) {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`);
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`,
);
}
};
this.ws.onclose = (event) => {
if (event.wasClean) {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`,
);
this.ws = null;
} else {
const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length-1)];
const retrySeconds =
retryBackoffSeconds[
Math.min(this.retryCount, retryBackoffSeconds.length - 1)
];
this.retryCount++;
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`);
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`,
);
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
}
};
this.ws.onerror = (event) => {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event);
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`,
event,
);
};
}
close() {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`);
console.log(
`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`,
);
const socket = this.ws;
const retryTimeout = this.retryTimeout;
if (socket !== null) {
@ -96,11 +128,13 @@ class Connection {
params.push(`since=${this.since}`);
}
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}`);
}
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

@ -1,5 +1,5 @@
import Connection from "./Connection";
import {hashCode} from "./utils";
import { hashCode } from "./utils";
/**
* The connection manager keeps track of active connections (WebSocket connections, see Connection).
@ -42,20 +42,25 @@ class ConnectionManager {
return;
}
console.log(`[ConnectionManager] Refreshing connections`);
const subscriptionsWithUsersAndConnectionId = await Promise.all(subscriptions
.map(async s => {
const [user] = users.filter(u => u.baseUrl === s.baseUrl);
const subscriptionsWithUsersAndConnectionId = await Promise.all(
subscriptions.map(async (s) => {
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
const connectionId = await makeConnectionId(s, user);
return {...s, user, connectionId};
}));
const targetIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId);
const deletedIds = Array.from(this.connections.keys()).filter(id => !targetIds.includes(id));
return { ...s, user, connectionId };
}),
);
const targetIds = subscriptionsWithUsersAndConnectionId.map(
(s) => s.connectionId,
);
const deletedIds = Array.from(this.connections.keys()).filter(
(id) => !targetIds.includes(id),
);
// Create and add new connections
subscriptionsWithUsersAndConnectionId.forEach(subscription => {
subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
const subscriptionId = subscription.id;
const connectionId = subscription.connectionId;
const added = !this.connections.get(connectionId)
const added = !this.connections.get(connectionId);
if (added) {
const baseUrl = subscription.baseUrl;
const topic = subscription.topic;
@ -68,17 +73,22 @@ class ConnectionManager {
topic,
user,
since,
(subscriptionId, notification) => this.notificationReceived(subscriptionId, notification),
(subscriptionId, state) => this.stateChanged(subscriptionId, state)
(subscriptionId, notification) =>
this.notificationReceived(subscriptionId, notification),
(subscriptionId, state) => this.stateChanged(subscriptionId, state),
);
this.connections.set(connectionId, connection);
console.log(`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})`);
console.log(
`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${
user ? user.username : "anonymous"
})`,
);
connection.start();
}
});
// Delete old connections
deletedIds.forEach(id => {
deletedIds.forEach((id) => {
console.log(`[ConnectionManager] Closing connection ${id}`);
const connection = this.connections.get(id);
this.connections.delete(id);
@ -91,7 +101,10 @@ class ConnectionManager {
try {
this.stateListener(subscriptionId, state);
} 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 {
this.notificationListener(subscriptionId, notification);
} 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) => {
return (user)
return user
? hashCode(`${subscription.id}|${user.username}|${user.password}`)
: hashCode(`${subscription.id}`);
}
};
const connectionManager = new 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 subscriptionManager from "./SubscriptionManager";
import logo from "../img/ntfy.png";
@ -23,10 +30,12 @@ class Notifier {
const title = formatTitleWithDefault(notification, displayName);
// 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, {
body: message,
icon: logo
icon: logo,
});
if (notification.click) {
n.onclick = (e) => openUrl(notification.click);
@ -46,7 +55,7 @@ class Notifier {
}
granted() {
return this.supported() && Notification.permission === 'granted';
return this.supported() && Notification.permission === "granted";
}
maybeRequestPermission(cb) {
@ -56,7 +65,7 @@ class Notifier {
}
if (!this.granted()) {
Notification.requestPermission().then((permission) => {
const granted = permission === 'granted';
const granted = permission === "granted";
cb(granted);
});
}
@ -66,7 +75,7 @@ class Notifier {
if (subscription.mutedUntil === 1) {
return false;
}
const priority = (notification.priority) ? notification.priority : 3;
const priority = notification.priority ? notification.priority : 3;
const minPriority = await prefs.minPriority();
if (priority < minPriority) {
return false;
@ -79,7 +88,7 @@ class Notifier {
}
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
*/
contextSupported() {
return location.protocol === 'https:'
|| location.hostname.match('^127.')
|| location.hostname === 'localhost';
return (
location.protocol === "https:" ||
location.hostname.match("^127.") ||
location.hostname === "localhost"
);
}
}

View file

@ -34,12 +34,18 @@ class Poller {
console.log(`[Poller] Polling ${subscription.id}`);
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) {
console.log(`[Poller] No new notifications found for ${subscription.id}`);
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);
}

View file

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

View file

@ -20,12 +20,15 @@ class Pruner {
async prune() {
const deleteAfterSeconds = await prefs.deleteAfter();
const pruneThresholdTimestamp = Math.round(Date.now()/1000) - deleteAfterSeconds;
const pruneThresholdTimestamp =
Math.round(Date.now() / 1000) - deleteAfterSeconds;
if (deleteAfterSeconds === 0) {
console.log(`[Pruner] Pruning is disabled. Skipping.`);
return;
}
console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`);
console.log(
`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`,
);
try {
await subscriptionManager.pruneNotifications(pruneThresholdTimestamp);
} catch (e) {

View file

@ -1,20 +1,22 @@
import db from "./db";
import {topicUrl} from "./utils";
import { topicUrl } from "./utils";
class SubscriptionManager {
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
async all() {
const subscriptions = await db.subscriptions.toArray();
await Promise.all(subscriptions.map(async s => {
await Promise.all(
subscriptions.map(async (s) => {
s.new = await db.notifications
.where({ subscriptionId: s.id, new: 1 })
.count();
}));
}),
);
return subscriptions;
}
async get(subscriptionId) {
return await db.subscriptions.get(subscriptionId)
return await db.subscriptions.get(subscriptionId);
}
async add(baseUrl, topic) {
@ -23,7 +25,7 @@ class SubscriptionManager {
baseUrl: baseUrl,
topic: topic,
mutedUntil: 0,
last: null
last: null,
};
await db.subscriptions.put(subscription);
return subscription;
@ -35,9 +37,7 @@ class SubscriptionManager {
async remove(subscriptionId) {
await db.subscriptions.delete(subscriptionId);
await db.notifications
.where({subscriptionId: subscriptionId})
.delete();
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
}
async first() {
@ -51,7 +51,7 @@ class SubscriptionManager {
return db.notifications
.orderBy("time") // Sort by time first
.filter(n => n.subscriptionId === subscriptionId)
.filter((n) => n.subscriptionId === subscriptionId)
.reverse()
.toArray();
}
@ -73,7 +73,7 @@ class SubscriptionManager {
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab
await db.subscriptions.update(subscriptionId, {
last: notification.id
last: notification.id,
});
} catch (e) {
console.error(`[SubscriptionManager] Error adding notification`, e);
@ -83,12 +83,13 @@ class SubscriptionManager {
/** Adds/replaces notifications, will not throw if they exist */
async addNotifications(subscriptionId, notifications) {
const notificationsWithSubscriptionId = notifications
.map(notification => ({ ...notification, subscriptionId }));
const notificationsWithSubscriptionId = notifications.map(
(notification) => ({ ...notification, subscriptionId }),
);
const lastNotificationId = notifications.at(-1).id;
await db.notifications.bulkPut(notificationsWithSubscriptionId);
await db.subscriptions.update(subscriptionId, {
last: lastNotificationId
last: lastNotificationId,
});
}
@ -110,39 +111,33 @@ class SubscriptionManager {
}
async deleteNotifications(subscriptionId) {
await db.notifications
.where({subscriptionId: subscriptionId})
.delete();
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
}
async markNotificationRead(notificationId) {
await db.notifications
.where({id: notificationId})
.modify({new: 0});
await db.notifications.where({ id: notificationId }).modify({ new: 0 });
}
async markNotificationsRead(subscriptionId) {
await db.notifications
.where({subscriptionId: subscriptionId, new: 1})
.modify({new: 0});
.where({ subscriptionId: subscriptionId, new: 1 })
.modify({ new: 0 });
}
async setMutedUntil(subscriptionId, mutedUntil) {
await db.subscriptions.update(subscriptionId, {
mutedUntil: mutedUntil
mutedUntil: mutedUntil,
});
}
async setDisplayName(subscriptionId, displayName) {
await db.subscriptions.update(subscriptionId, {
displayName: displayName
displayName: displayName,
});
}
async pruneNotifications(thresholdTimestamp) {
await db.notifications
.where("time").below(thresholdTimestamp)
.delete();
await db.notifications.where("time").below(thresholdTimestamp).delete();
}
}

View file

@ -1,4 +1,4 @@
import Dexie from 'dexie';
import Dexie from "dexie";
// Uses Dexie.js
// https://dexie.org/docs/API-Reference#quick-reference
@ -6,13 +6,13 @@ import Dexie from 'dexie';
// Notes:
// - 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({
subscriptions: '&id,baseUrl',
notifications: '&id,subscriptionId,time,new,[subscriptionId+new]', // compound key for query performance
users: '&baseUrl,username',
prefs: '&key'
subscriptions: "&id,baseUrl",
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
users: "&baseUrl,username",
prefs: "&key",
});
export default db;

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
import {rawEmojis} from "./emojis";
import { rawEmojis } from "./emojis";
import beep from "../sounds/beep.mp3";
import juntos from "../sounds/juntos.mp3";
import pristine from "../sounds/pristine.mp3";
@ -7,17 +7,23 @@ import dadum from "../sounds/dadum.mp3";
import pop from "../sounds/pop.mp3";
import popSwoosh from "../sounds/pop-swoosh.mp3";
import config from "./config";
import {Base64} from 'js-base64';
import { Base64 } from "js-base64";
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("http://", "ws://");
export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const topicUrlJson = (baseUrl, topic) =>
`${topicUrl(baseUrl, topic)}/json`;
export const topicUrlJsonPoll = (baseUrl, topic) =>
`${topicUrlJson(baseUrl, topic)}?poll=1`;
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) =>
`${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
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 shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
@ -25,18 +31,18 @@ export const expandSecureUrl = (url) => `https://${url}`;
export const validUrl = (url) => {
return url.match(/^https?:\/\/.+/);
}
};
export const validTopic = (topic) => {
if (disallowedTopic(topic)) {
return false;
}
return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
}
};
export const disallowedTopic = (topic) => {
return config.disallowedTopics.includes(topic);
}
};
export const topicDisplayName = (subscription) => {
if (subscription.displayName) {
@ -49,16 +55,16 @@ export const topicDisplayName = (subscription) => {
// Format emojis (see emoji.js)
const emojis = {};
rawEmojis.forEach(emoji => {
emoji.aliases.forEach(alias => {
rawEmojis.forEach((emoji) => {
emoji.aliases.forEach((alias) => {
emojis[alias] = emoji.emoji;
});
});
const toEmojis = (tags) => {
if (!tags) return [];
else return tags.filter(tag => tag in emojis).map(tag => emojis[tag]);
}
else return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]);
};
export const formatTitleWithDefault = (m, fallback) => {
if (m.title) {
@ -91,39 +97,41 @@ export const formatMessage = (m) => {
export const unmatchedTags = (tags) => {
if (!tags) return [];
else return tags.filter(tag => !(tag in emojis));
}
else return tags.filter((tag) => !(tag in emojis));
};
export const maybeWithBasicAuth = (headers, user) => {
if (user) {
headers['Authorization'] = `Basic ${encodeBase64(`${user.username}:${user.password}`)}`;
headers["Authorization"] = `Basic ${encodeBase64(
`${user.username}:${user.password}`,
)}`;
}
return headers;
}
};
export const basicAuth = (username, password) => {
return `Basic ${encodeBase64(`${username}:${password}`)}`;
}
};
export const encodeBase64 = (s) => {
return Base64.encode(s);
}
};
export const encodeBase64Url = (s) => {
return Base64.encodeURI(s);
}
};
export const maybeAppendActionErrors = (message, notification) => {
const actionErrors = (notification.actions ?? [])
.map(action => action.error)
.filter(action => !!action)
.join("\n")
.map((action) => action.error)
.filter((action) => !!action)
.join("\n");
if (actionErrors.length === 0) {
return message;
} else {
return `${message}\n\n${actionErrors}`;
}
}
};
export const shuffle = (arr) => {
let j, x;
@ -134,73 +142,75 @@ export const shuffle = (arr) => {
arr[j] = x;
}
return arr;
}
};
export const splitNoEmpty = (s, delimiter) => {
return s
.split(delimiter)
.map(x => x.trim())
.filter(x => x !== "");
}
.map((x) => x.trim())
.filter((x) => x !== "");
};
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
export const hashCode = async (s) => {
let hash = 0;
for (let i = 0; i < s.length; i++) {
const char = s.charCodeAt(i);
hash = ((hash<<5)-hash)+char;
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}
};
export const formatShortDateTime = (timestamp) => {
return new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'})
.format(new Date(timestamp * 1000));
}
return new Intl.DateTimeFormat("default", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(timestamp * 1000));
};
export const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return '0 bytes';
if (bytes === 0) return "0 bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
};
export const openUrl = (url) => {
window.open(url, "_blank", "noopener,noreferrer");
};
export const sounds = {
"ding": {
ding: {
file: ding,
label: "Ding"
label: "Ding",
},
"juntos": {
juntos: {
file: juntos,
label: "Juntos"
label: "Juntos",
},
"pristine": {
pristine: {
file: pristine,
label: "Pristine"
label: "Pristine",
},
"dadum": {
dadum: {
file: dadum,
label: "Dadum"
label: "Dadum",
},
"pop": {
pop: {
file: pop,
label: "Pop"
label: "Pop",
},
"pop-swoosh": {
file: popSwoosh,
label: "Pop swoosh"
label: "Pop swoosh",
},
"beep": {
beep: {
file: beep,
label: "Beep"
}
label: "Beep",
},
};
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
export async function* fetchLinesIterator(fileURL, headers) {
const utf8Decoder = new TextDecoder('utf-8');
const utf8Decoder = new TextDecoder("utf-8");
const response = await fetch(fileURL, {
headers: headers
headers: headers,
});
const reader = response.body.getReader();
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;
let startIndex = 0;
@ -229,7 +239,7 @@ export async function* fetchLinesIterator(fileURL, headers) {
}
let remainder = chunk.substr(startIndex);
({ value: chunk, done: readerDone } = await reader.read());
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : '');
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : "");
startIndex = re.lastIndex = 0;
continue;
}
@ -242,10 +252,11 @@ export async function* fetchLinesIterator(fileURL, headers) {
}
export const randomAlphanumericString = (len) => {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const alphabet =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let id = "";
for (let i = 0; i < len; i++) {
id += alphabet[(Math.random() * alphabet.length) | 0];
}
return id;
}
};

View file

@ -5,25 +5,30 @@ import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu";
import Typography from "@mui/material/Typography";
import * as React from "react";
import {useEffect, useRef, useState} from "react";
import { useEffect, useRef, useState } from "react";
import Box from "@mui/material/Box";
import {formatShortDateTime, shuffle, topicDisplayName, topicShortUrl} from "../app/utils";
import {useLocation, useNavigate} from "react-router-dom";
import ClickAwayListener from '@mui/material/ClickAwayListener';
import Grow from '@mui/material/Grow';
import Paper from '@mui/material/Paper';
import Popper from '@mui/material/Popper';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
import {
formatShortDateTime,
shuffle,
topicDisplayName,
topicShortUrl,
} from "../app/utils";
import { useLocation, useNavigate } from "react-router-dom";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import Grow from "@mui/material/Grow";
import Paper from "@mui/material/Paper";
import Popper from "@mui/material/Popper";
import MenuItem from "@mui/material/MenuItem";
import MenuList from "@mui/material/MenuList";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsIcon from '@mui/icons-material/Notifications';
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
import NotificationsIcon from "@mui/icons-material/Notifications";
import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
import api from "../app/Api";
import routes from "./routes";
import subscriptionManager from "../app/SubscriptionManager";
import logo from "../img/ntfy.svg";
import {useTranslation} from "react-i18next";
import {Portal, Snackbar} from "@mui/material";
import { useTranslation } from "react-i18next";
import { Portal, Snackbar } from "@mui/material";
import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog";
const ActionBar = (props) => {
@ -36,18 +41,21 @@ const ActionBar = (props) => {
title = t("action_bar_settings");
}
return (
<AppBar position="fixed" sx={{
width: '100%',
<AppBar
position="fixed"
sx={{
width: "100%",
zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
ml: { sm: `${Navigation.width}px` }
}}>
<Toolbar sx={{pr: '24px'}}>
ml: { sm: `${Navigation.width}px` },
}}
>
<Toolbar sx={{ pr: "24px" }}>
<IconButton
color="inherit"
edge="start"
aria-label={t("action_bar_show_menu")}
onClick={props.onMobileDrawerToggle}
sx={{ mr: 2, display: { sm: 'none' } }}
sx={{ mr: 2, display: { sm: "none" } }}
>
<MenuIcon />
</IconButton>
@ -56,19 +64,20 @@ const ActionBar = (props) => {
src={logo}
alt={t("action_bar_logo_alt")}
sx={{
display: { xs: 'none', sm: 'block' },
marginRight: '10px',
height: '28px'
display: { xs: "none", sm: "block" },
marginRight: "10px",
height: "28px",
}}
/>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{title}
</Typography>
{props.selected &&
{props.selected && (
<SettingsIcons
subscription={props.selected}
onUnsubscribe={props.onUnsubscribe}
/>}
/>
)}
</Toolbar>
</AppBar>
);
@ -80,7 +89,8 @@ const SettingsIcons = (props) => {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [snackOpen, setSnackOpen] = useState(false);
const [subscriptionSettingsOpen, setSubscriptionSettingsOpen] = useState(false);
const [subscriptionSettingsOpen, setSubscriptionSettingsOpen] =
useState(false);
const anchorRef = useRef(null);
const subscription = props.subscription;
@ -89,9 +99,9 @@ const SettingsIcons = (props) => {
};
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);
}
};
const handleClose = (event) => {
if (anchorRef.current && anchorRef.current.contains(event.target)) {
@ -102,7 +112,9 @@ const SettingsIcons = (props) => {
const handleClearAll = async (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);
};
@ -120,15 +132,30 @@ const SettingsIcons = (props) => {
const handleSubscriptionSettings = async () => {
setSubscriptionSettingsOpen(true);
}
};
const handleSendTestMessage = async () => {
const baseUrl = props.subscription.baseUrl;
const topic = props.subscription.topic;
const tags = shuffle([
"grinning", "octopus", "upside_down_face", "palm_tree", "maple_leaf", "apple", "skull", "warning", "jack_o_lantern",
"de-server-1", "backups", "cron-script", "script-error", "phils-automation", "mouse", "go-rocks", "hi-ben"])
.slice(0, Math.round(Math.random() * 4));
"grinning",
"octopus",
"upside_down_face",
"palm_tree",
"maple_leaf",
"apple",
"skull",
"warning",
"jack_o_lantern",
"de-server-1",
"backups",
"cron-script",
"script-error",
"phils-automation",
"mouse",
"go-rocks",
"hi-ben",
]).slice(0, Math.round(Math.random() * 4));
const priority = shuffle([1, 2, 3, 4, 5])[0];
const title = shuffle([
"",
@ -140,39 +167,43 @@ const SettingsIcons = (props) => {
"I don't really like apples",
"My favorite TV show is The Wire. You should watch it!",
"You can attach files and URLs to messages too",
"You can delay messages up to 3 days"
"You can delay messages up to 3 days",
])[0];
const nowSeconds = Math.round(Date.now()/1000);
const nowSeconds = Math.round(Date.now() / 1000);
const message = shuffle([
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(
nowSeconds,
)} right now. Is that early or late?`,
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
`Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`Alright then, it's ${formatShortDateTime(
nowSeconds,
)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`,
])[0];
try {
await api.publish(baseUrl, topic, message, {
title: title,
priority: priority,
tags: tags
tags: tags,
});
} catch (e) {
console.log(`[ActionBar] Error publishing message`, e);
setSnackOpen(true);
}
setOpen(false);
}
};
const handleListKeyDown = (event) => {
if (event.key === 'Tab') {
if (event.key === "Tab") {
event.preventDefault();
setOpen(false);
} else if (event.key === 'Escape') {
} else if (event.key === "Escape") {
setOpen(false);
}
}
};
// return focus to the button when we transitioned from !open -> open
const prevOpen = useRef(open);
@ -185,11 +216,29 @@ const SettingsIcons = (props) => {
return (
<>
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} sx={{marginRight: 0}} aria-label={t("action_bar_toggle_mute")}>
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
<IconButton
color="inherit"
size="large"
edge="end"
onClick={handleToggleMute}
sx={{ marginRight: 0 }}
aria-label={t("action_bar_toggle_mute")}
>
{subscription.mutedUntil ? (
<NotificationsOffIcon />
) : (
<NotificationsIcon />
)}
</IconButton>
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}>
<MoreVertIcon/>
<IconButton
color="inherit"
size="large"
edge="end"
ref={anchorRef}
onClick={handleToggleOpen}
aria-label={t("action_bar_toggle_action_menu")}
>
<MoreVertIcon />
</IconButton>
<Popper
open={open}
@ -199,18 +248,29 @@ const SettingsIcons = (props) => {
transition
disablePortal
>
{({TransitionProps, placement}) => (
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{transformOrigin: placement === 'bottom-start' ? 'left top' : 'left bottom'}}
style={{
transformOrigin:
placement === "bottom-start" ? "left top" : "left bottom",
}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList autoFocusItem={open} onKeyDown={handleListKeyDown}>
<MenuItem onClick={handleSubscriptionSettings}>{t("action_bar_subscription_settings")}</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>
<MenuItem onClick={handleSubscriptionSettings}>
{t("action_bar_subscription_settings")}
</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>
</ClickAwayListener>
</Paper>

View file

@ -1,28 +1,39 @@
import * as React from 'react';
import * as React from "react";
import { Suspense } from "react";
import {useEffect, useState} from 'react';
import Box from '@mui/material/Box';
import {ThemeProvider} from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import Toolbar from '@mui/material/Toolbar';
import { useEffect, useState } from "react";
import Box from "@mui/material/Box";
import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import Toolbar from "@mui/material/Toolbar";
import Notifications from "./Notifications";
import theme from "./theme";
import Navigation from "./Navigation";
import ActionBar from "./ActionBar";
import notifier from "../app/Notifier";
import Preferences from "./Preferences";
import {useLiveQuery} from "dexie-react-hooks";
import { useLiveQuery } from "dexie-react-hooks";
import subscriptionManager from "../app/SubscriptionManager";
import userManager from "../app/UserManager";
import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from "react-router-dom";
import {expandUrl} from "../app/utils";
import {
BrowserRouter,
Outlet,
Route,
Routes,
useOutletContext,
useParams,
} from "react-router-dom";
import { expandUrl } from "../app/utils";
import ErrorBoundary from "./ErrorBoundary";
import routes from "./routes";
import {useAutoSubscribe, useBackgroundProcesses, useConnectionListeners} from "./hooks";
import {
useAutoSubscribe,
useBackgroundProcesses,
useConnectionListeners,
} from "./hooks";
import PublishDialog from "./PublishDialog";
import Messaging from "./Messaging";
import "./i18n"; // Translations!
import {Backdrop, CircularProgress} from "@mui/material";
import { Backdrop, CircularProgress } from "@mui/material";
// TODO races when two tabs are open
// TODO investigate service workers
@ -32,14 +43,20 @@ const App = () => {
<Suspense fallback={<Loader />}>
<BrowserRouter>
<ThemeProvider theme={theme}>
<CssBaseline/>
<CssBaseline />
<ErrorBoundary>
<Routes>
<Route element={<Layout/>}>
<Route path={routes.root} element={<AllSubscriptions/>}/>
<Route path={routes.settings} element={<Preferences/>}/>
<Route path={routes.subscription} element={<SingleSubscription/>}/>
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
<Route element={<Layout />}>
<Route path={routes.root} element={<AllSubscriptions />} />
<Route path={routes.settings} element={<Preferences />} />
<Route
path={routes.subscription}
element={<SingleSubscription />}
/>
<Route
path={routes.subscriptionExternal}
element={<SingleSubscription />}
/>
</Route>
</Routes>
</ErrorBoundary>
@ -47,30 +64,37 @@ const App = () => {
</BrowserRouter>
</Suspense>
);
}
};
const AllSubscriptions = () => {
const { subscriptions } = useOutletContext();
return <Notifications mode="all" subscriptions={subscriptions}/>;
return <Notifications mode="all" subscriptions={subscriptions} />;
};
const SingleSubscription = () => {
const { subscriptions, selected } = useOutletContext();
useAutoSubscribe(subscriptions, selected);
return <Notifications mode="one" subscription={selected}/>;
return <Notifications mode="one" subscription={selected} />;
};
const Layout = () => {
const params = useParams();
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
const [notificationsGranted, setNotificationsGranted] = useState(
notifier.granted(),
);
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all());
const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
const [selected] = (subscriptions || []).filter(s => {
return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic)
|| (window.location.origin === s.baseUrl && params.topic === s.topic)
const newNotificationsCount =
subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
const [selected] = (subscriptions || []).filter((s) => {
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);
@ -78,8 +102,8 @@ const Layout = () => {
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
return (
<Box sx={{display: 'flex'}}>
<CssBaseline/>
<Box sx={{ display: "flex" }}>
<CssBaseline />
<ActionBar
selected={selected}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
@ -91,11 +115,13 @@ const Layout = () => {
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onNotificationGranted={setNotificationsGranted}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
onPublishMessageClick={() =>
setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)
}
/>
<Main>
<Toolbar/>
<Outlet context={{ subscriptions, selected }}/>
<Toolbar />
<Outlet context={{ subscriptions, selected }} />
</Main>
<Messaging
selected={selected}
@ -104,7 +130,7 @@ const Layout = () => {
/>
</Box>
);
}
};
const Main = (props) => {
return (
@ -112,14 +138,17 @@ const Main = (props) => {
id="main"
component="main"
sx={{
display: 'flex',
display: "flex",
flexGrow: 1,
flexDirection: 'column',
flexDirection: "column",
padding: 3,
width: {sm: `calc(100% - ${Navigation.width}px)`},
height: '100vh',
overflow: 'auto',
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
width: { sm: `calc(100% - ${Navigation.width}px)` },
height: "100vh",
overflow: "auto",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}}
>
{props.children}
@ -132,7 +161,10 @@ const Loader = () => (
open={true}
sx={{
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 />
@ -140,7 +172,8 @@ const Loader = () => (
);
const updateTitle = (newNotificationsCount) => {
document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy";
}
document.title =
newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
};
export default App;

View file

@ -5,7 +5,7 @@ import fileImage from "../img/file-image.svg";
import fileVideo from "../img/file-video.svg";
import fileAudio from "../img/file-audio.svg";
import fileApp from "../img/file-app.svg";
import {useTranslation} from "react-i18next";
import { useTranslation } from "react-i18next";
const AttachmentIcon = (props) => {
const { t } = useTranslation();
@ -14,13 +14,13 @@ const AttachmentIcon = (props) => {
if (!type) {
imageFile = fileDocument;
imageLabel = t("notifications_attachment_file_image");
} else if (type.startsWith('image/')) {
} else if (type.startsWith("image/")) {
imageFile = fileImage;
imageLabel = t("notifications_attachment_file_video");
} else if (type.startsWith('video/')) {
} else if (type.startsWith("video/")) {
imageFile = fileVideo;
imageLabel = t("notifications_attachment_file_video");
} else if (type.startsWith('audio/')) {
} else if (type.startsWith("audio/")) {
imageFile = fileAudio;
imageLabel = t("notifications_attachment_file_audio");
} else if (type === "application/vnd.android.package-archive") {
@ -37,11 +37,11 @@ const AttachmentIcon = (props) => {
alt={imageLabel}
loading="lazy"
sx={{
width: '28px',
height: '28px'
width: "28px",
height: "28px",
}}
/>
);
}
};
export default AttachmentIcon;

View file

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

View file

@ -1,15 +1,15 @@
import * as React from 'react';
import {useRef, useState} from 'react';
import Typography from '@mui/material/Typography';
import {rawEmojis} from '../app/emojis';
import * as React from "react";
import { useRef, useState } from "react";
import Typography from "@mui/material/Typography";
import { rawEmojis } from "../app/emojis";
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField";
import {ClickAwayListener, Fade, InputAdornment, styled} from "@mui/material";
import { ClickAwayListener, Fade, InputAdornment, styled } from "@mui/material";
import IconButton from "@mui/material/IconButton";
import {Close} from "@mui/icons-material";
import { Close } from "@mui/icons-material";
import Popper from "@mui/material/Popper";
import {splitNoEmpty} from "../app/utils";
import {useTranslation} from "react-i18next";
import { splitNoEmpty } from "../app/utils";
import { useTranslation } from "react-i18next";
// Create emoji list by category and create a search base (string with all search words)
//
@ -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.
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;
rawEmojis.forEach(emoji => {
rawEmojis.forEach((emoji) => {
if (!emojisByCategory[emoji.category]) {
emojisByCategory[emoji.category] = [];
}
try {
const unicodeVersion = parseFloat(emoji.unicode_version);
const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
const supportedEmoji =
unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
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 };
emojisByCategory[emoji.category].push(emojiWithSearchBase);
}
@ -59,42 +63,60 @@ const EmojiPicker = (props) => {
{({ TransitionProps }) => (
<ClickAwayListener onClickAway={props.onClose}>
<Fade {...TransitionProps} timeout={350}>
<Box sx={{
<Box
sx={{
boxShadow: 3,
padding: 2,
paddingRight: 0,
paddingBottom: 1,
width: "380px",
maxHeight: "300px",
backgroundColor: 'background.paper',
overflowY: "scroll"
}}>
backgroundColor: "background.paper",
overflowY: "scroll",
}}
>
<TextField
inputRef={searchRef}
margin="dense"
size="small"
placeholder={t("emoji_picker_search_placeholder")}
value={search}
onChange={ev => setSearch(ev.target.value)}
onChange={(ev) => setSearch(ev.target.value)}
type="text"
variant="standard"
fullWidth
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
inputProps={{
role: "searchbox",
"aria-label": t("emoji_picker_search_placeholder")
"aria-label": t("emoji_picker_search_placeholder"),
}}
InputProps={{
endAdornment:
<InputAdornment position="end" sx={{ display: (search) ? '' : 'none' }}>
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}>
<Close/>
endAdornment: (
<InputAdornment
position="end"
sx={{ display: search ? "" : "none" }}
>
<IconButton
size="small"
onClick={handleSearchClear}
edge="end"
aria-label={t("emoji_picker_search_clear")}
>
<Close />
</IconButton>
</InputAdornment>
),
}}
/>
<Box sx={{ display: "flex", flexWrap: "wrap", paddingRight: 0, marginTop: 1 }}>
{Object.keys(emojisByCategory).map(category =>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
paddingRight: 0,
marginTop: 1,
}}
>
{Object.keys(emojisByCategory).map((category) => (
<Category
key={category}
title={category}
@ -102,7 +124,7 @@ const EmojiPicker = (props) => {
search={searchFields}
onPick={props.onEmojiPick}
/>
)}
))}
</Box>
</Box>
</Fade>
@ -116,19 +138,19 @@ const Category = (props) => {
const showTitle = props.search.length === 0;
return (
<>
{showTitle &&
{showTitle && (
<Typography variant="body1" sx={{ width: "100%", marginBottom: 1 }}>
{props.title}
</Typography>
}
{props.emojis.map(emoji =>
)}
{props.emojis.map((emoji) => (
<Emoji
key={emoji.aliases[0]}
emoji={emoji}
search={props.search}
onClick={() => props.onPick(emoji.aliases[0])}
/>
)}
))}
</>
);
};
@ -142,7 +164,7 @@ const Emoji = (props) => {
onClick={props.onClick}
title={title}
aria-label={title}
style={{ display: (matches) ? '' : 'none' }}
style={{ display: matches ? "" : "none" }}
>
{props.emoji.emoji}
</EmojiDiv>
@ -160,8 +182,8 @@ const EmojiDiv = styled("div")({
cursor: "pointer",
opacity: 0.85,
"&:hover": {
opacity: 1
}
opacity: 1,
},
});
const emojiMatches = (emoji, words) => {
@ -174,6 +196,6 @@ const emojiMatches = (emoji, words) => {
}
}
return true;
}
};
export default EmojiPicker;

View file

@ -1,8 +1,8 @@
import * as React from "react";
import StackTrace from "stacktrace-js";
import {CircularProgress, Link} from "@mui/material";
import { CircularProgress, Link } from "@mui/material";
import Button from "@mui/material/Button";
import {Trans, withTranslation} from "react-i18next";
import { Trans, withTranslation } from "react-i18next";
class ErrorBoundaryImpl extends React.Component {
constructor(props) {
@ -11,7 +11,7 @@ class ErrorBoundaryImpl extends React.Component {
error: false,
originalStack: 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
// - https://github.com/dexie/Dexie.js/issues/312
// - https://bugzilla.mozilla.org/show_bug.cgi?id=781982
const isUnsupportedIndexedDB = error?.name === "InvalidStateError" ||
(error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1);
const isUnsupportedIndexedDB =
error?.name === "InvalidStateError" ||
(error?.name === "DatabaseClosedError" &&
error?.message?.indexOf("InvalidStateError") !== -1);
if (isUnsupportedIndexedDB) {
this.handleUnsupportedIndexedDB();
@ -36,17 +38,24 @@ class ErrorBoundaryImpl extends React.Component {
const prettierOriginalStack = info.componentStack
.trim()
.split("\n")
.map(line => ` at ${line}`)
.map((line) => ` at ${line}`)
.join("\n");
this.setState({
error: true,
originalStack: `${error.toString()}\n${prettierOriginalStack}`
originalStack: `${error.toString()}\n${prettierOriginalStack}`,
});
// Fetch additional info and a better stack trace
StackTrace.fromError(error).then(stack => {
StackTrace.fromError(error).then((stack) => {
console.error("[ErrorBoundary] Stacktrace fetched", stack);
const niceStack = `${error.toString()}\n` + stack.map( el => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
const niceStack =
`${error.toString()}\n` +
stack
.map(
(el) =>
` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`,
)
.join("\n");
this.setState({ niceStack });
});
}
@ -54,7 +63,7 @@ class ErrorBoundaryImpl extends React.Component {
handleUnsupportedIndexedDB() {
this.setState({
error: true,
unsupportedIndexedDB: true
unsupportedIndexedDB: true,
});
}
@ -81,15 +90,17 @@ class ErrorBoundaryImpl extends React.Component {
renderUnsupportedIndexedDB() {
const { t } = this.props;
return (
<div style={{margin: '20px'}}>
<div style={{ margin: "20px" }}>
<h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2>
<p style={{maxWidth: "600px"}}>
<p style={{ maxWidth: "600px" }}>
<Trans
i18nKey="error_boundary_unsupported_indexeddb_description"
components={{
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues/208"/>,
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
githubLink: (
<Link href="https://github.com/binwiederhier/ntfy/issues/208" />
),
discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />,
}}
/>
</p>
@ -100,25 +111,37 @@ class ErrorBoundaryImpl extends React.Component {
renderError() {
const { t } = this.props;
return (
<div style={{margin: '20px'}}>
<div style={{ margin: "20px" }}>
<h2>{t("error_boundary_title")} 😮</h2>
<p>
<Trans
i18nKey="error_boundary_description"
components={{
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues"/>,
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
githubLink: (
<Link href="https://github.com/binwiederhier/ntfy/issues" />
),
discordLink: <Link href="https://discord.gg/cT7ECsZj9w" />,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org" />,
}}
/>
</p>
<p>
<Button variant="outlined" onClick={() => this.copyStack()}>{t("error_boundary_button_copy_stack_trace")}</Button>
<Button variant="outlined" onClick={() => this.copyStack()}>
{t("error_boundary_button_copy_stack_trace")}
</Button>
</p>
<h3>{t("error_boundary_stack_trace")}</h3>
{this.state.niceStack
? <pre>{this.state.niceStack}</pre>
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> {t("error_boundary_gathering_info")}</>}
{this.state.niceStack ? (
<pre>{this.state.niceStack}</pre>
) : (
<>
<CircularProgress
size="20px"
sx={{ verticalAlign: "text-bottom" }}
/>{" "}
{t("error_boundary_gathering_info")}
</>
)}
<pre>{this.state.originalStack}</pre>
</div>
);

View file

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

View file

@ -1,6 +1,6 @@
import Drawer from "@mui/material/Drawer";
import * as React from "react";
import {useState} from "react";
import { useState } from "react";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
@ -11,30 +11,41 @@ import List from "@mui/material/List";
import SettingsIcon from "@mui/icons-material/Settings";
import AddIcon from "@mui/icons-material/Add";
import SubscribeDialog from "./SubscribeDialog";
import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader} from "@mui/material";
import {
Alert,
AlertTitle,
Badge,
CircularProgress,
Link,
ListSubheader,
} from "@mui/material";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import {openUrl, topicDisplayName, topicUrl} from "../app/utils";
import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
import routes from "./routes";
import {ConnectionState} from "../app/Connection";
import {useLocation, useNavigate} from "react-router-dom";
import { ConnectionState } from "../app/Connection";
import { useLocation, useNavigate } from "react-router-dom";
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 notifier from "../app/Notifier";
import config from "../app/config";
import ArticleIcon from '@mui/icons-material/Article';
import {Trans, useTranslation} from "react-i18next";
import ArticleIcon from "@mui/icons-material/Article";
import { Trans, useTranslation } from "react-i18next";
const navWidth = 280;
const Navigation = (props) => {
const navigationList = <NavList {...props}/>;
const navigationList = <NavList {...props} />;
return (
<Box
component="nav"
role="navigation"
sx={{width: {sm: Navigation.width}, flexShrink: {sm: 0}}}
sx={{ width: { sm: Navigation.width }, flexShrink: { sm: 0 } }}
>
{/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
<Drawer
@ -44,8 +55,8 @@ const Navigation = (props) => {
onClose={props.onMobileDrawerToggle}
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
display: { xs: "block", sm: "none" },
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
}}
>
{navigationList}
@ -56,8 +67,8 @@ const Navigation = (props) => {
variant="permanent"
role="menubar"
sx={{
display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
display: { xs: "none", sm: "block" },
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
}}
>
{navigationList}
@ -76,66 +87,111 @@ const NavList = (props) => {
const handleSubscribeReset = () => {
setSubscribeDialogOpen(false);
setSubscribeDialogKey(prev => prev+1);
}
setSubscribeDialogKey((prev) => prev + 1);
};
const handleSubscribeSubmit = (subscription) => {
console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
console.log(
`[Navigation] New subscription: ${subscription.id}`,
subscription,
);
handleSubscribeReset();
navigate(routes.forSubscription(subscription));
handleRequestNotificationPermission();
}
};
const handleRequestNotificationPermission = () => {
notifier.maybeRequestPermission(granted => props.onNotificationGranted(granted))
notifier.maybeRequestPermission((granted) =>
props.onNotificationGranted(granted),
);
};
const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted;
const navListPadding = (showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox) ? '0' : '';
const showNotificationContextNotSupportedBox =
notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
const showNotificationGrantBox =
notifier.supported() &&
props.subscriptions?.length > 0 &&
!props.notificationsGranted;
const navListPadding =
showNotificationGrantBox ||
showNotificationBrowserNotSupportedBox ||
showNotificationContextNotSupportedBox
? "0"
: "";
return (
<>
<Toolbar sx={{ display: { xs: 'none', sm: 'block' } }}/>
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
<List component="nav" sx={{ paddingTop: navListPadding }}>
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert/>}
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
{!showSubscriptionsList &&
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
<ListItemIcon><ChatBubble/></ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")}/>
</ListItemButton>}
{showSubscriptionsList &&
{showNotificationBrowserNotSupportedBox && (
<NotificationBrowserNotSupportedAlert />
)}
{showNotificationContextNotSupportedBox && (
<NotificationContextNotSupportedAlert />
)}
{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")} />
</ListItemButton>
)}
{showSubscriptionsList && (
<>
<ListSubheader>{t("nav_topics_title")}</ListSubheader>
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
<ListItemIcon><ChatBubble/></ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")}/>
<ListItemButton
onClick={() => navigate(routes.root)}
selected={location.pathname === config.appRoot}
>
<ListItemIcon>
<ChatBubble />
</ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")} />
</ListItemButton>
<SubscriptionList
subscriptions={props.subscriptions}
selectedSubscription={props.selectedSubscription}
/>
<Divider sx={{my: 1}}/>
</>}
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
<ListItemIcon><SettingsIcon/></ListItemIcon>
<ListItemText primary={t("nav_button_settings")}/>
<Divider sx={{ my: 1 }} />
</>
)}
<ListItemButton
onClick={() => navigate(routes.settings)}
selected={location.pathname === routes.settings}
>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_settings")} />
</ListItemButton>
<ListItemButton onClick={() => openUrl("/docs")}>
<ListItemIcon><ArticleIcon/></ListItemIcon>
<ListItemText primary={t("nav_button_documentation")}/>
<ListItemIcon>
<ArticleIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_documentation")} />
</ListItemButton>
<ListItemButton onClick={() => props.onPublishMessageClick()}>
<ListItemIcon><Send/></ListItemIcon>
<ListItemText primary={t("nav_button_publish_message")}/>
<ListItemIcon>
<Send />
</ListItemIcon>
<ListItemText primary={t("nav_button_publish_message")} />
</ListItemButton>
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
<ListItemIcon><AddIcon/></ListItemIcon>
<ListItemText primary={t("nav_button_subscribe")}/>
<ListItemIcon>
<AddIcon />
</ListItemIcon>
<ListItemText primary={t("nav_button_subscribe")} />
</ListItemButton>
</List>
<SubscribeDialog
@ -150,31 +206,45 @@ const NavList = (props) => {
};
const SubscriptionList = (props) => {
const sortedSubscriptions = props.subscriptions.sort( (a, b) => {
return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1;
const sortedSubscriptions = props.subscriptions.sort((a, b) => {
return topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1;
});
return (
<>
{sortedSubscriptions.map(subscription =>
{sortedSubscriptions.map((subscription) => (
<SubscriptionItem
key={subscription.id}
subscription={subscription}
selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id}
/>)}
selected={
props.selectedSubscription &&
props.selectedSubscription.id === subscription.id
}
/>
))}
</>
);
}
};
const SubscriptionItem = (props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const subscription = props.subscription;
const iconBadge = (subscription.new <= 99) ? subscription.new : "99+";
const icon = (subscription.state === ConnectionState.Connecting)
? <CircularProgress size="24px"/>
: <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>;
const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
const icon =
subscription.state === ConnectionState.Connecting ? (
<CircularProgress size="24px" />
) : (
<Badge
badgeContent={iconBadge}
invisible={subscription.new === 0}
color="primary"
>
<ChatBubbleOutlineIcon />
</Badge>
);
const displayName = topicDisplayName(subscription);
const ariaLabel = (subscription.state === ConnectionState.Connecting)
const ariaLabel =
subscription.state === ConnectionState.Connecting
? `${displayName} (${t("nav_button_connecting")})`
: displayName;
const handleClick = async () => {
@ -182,11 +252,19 @@ const SubscriptionItem = (props) => {
await subscriptionManager.markNotificationsRead(subscription.id);
};
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>
<ListItemText primary={displayName}/>
{subscription.mutedUntil > 0 &&
<ListItemIcon edge="end" aria-label={t("nav_button_muted")}><NotificationsOffOutlined /></ListItemIcon>}
<ListItemText primary={displayName} />
{subscription.mutedUntil > 0 && (
<ListItemIcon edge="end" aria-label={t("nav_button_muted")}>
<NotificationsOffOutlined />
</ListItemIcon>
)}
</ListItemButton>
);
};
@ -195,11 +273,11 @@ const NotificationGrantAlert = (props) => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{paddingTop: 2}}>
<Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_grant_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_grant_description")}</Typography>
<Button
sx={{float: 'right'}}
sx={{ float: "right" }}
color="inherit"
size="small"
onClick={props.onRequestPermissionClick}
@ -207,7 +285,7 @@ const NotificationGrantAlert = (props) => {
{t("alert_grant_button")}
</Button>
</Alert>
<Divider/>
<Divider />
</>
);
};
@ -216,11 +294,13 @@ const NotificationBrowserNotSupportedAlert = () => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{paddingTop: 2}}>
<Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_not_supported_description")}</Typography>
<Typography gutterBottom>
{t("alert_not_supported_description")}
</Typography>
</Alert>
<Divider/>
<Divider />
</>
);
};
@ -229,18 +309,24 @@ const NotificationContextNotSupportedAlert = () => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{paddingTop: 2}}>
<Alert severity="warning" sx={{ paddingTop: 2 }}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom>
<Trans
i18nKey="alert_not_supported_context_description"
components={{
mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener"/>
mdnLink: (
<Link
href="https://developer.mozilla.org/en-US/docs/Web/API/notification"
target="_blank"
rel="noopener"
/>
),
}}
/>
</Typography>
</Alert>
<Divider/>
<Divider />
</>
);
};

View file

@ -9,27 +9,32 @@ import {
Modal,
Snackbar,
Stack,
Tooltip
Tooltip,
} from "@mui/material";
import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography";
import * as React from "react";
import {useEffect, useState} from "react";
import { useEffect, useState } from "react";
import {
formatBytes,
formatMessage,
formatShortDateTime,
formatTitle, maybeAppendActionErrors,
formatTitle,
maybeAppendActionErrors,
openUrl,
shortUrl,
topicShortUrl,
unmatchedTags
unmatchedTags,
} from "../app/utils";
import IconButton from "@mui/material/IconButton";
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles";
import {useLiveQuery} from "dexie-react-hooks";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import {
LightboxBackdrop,
Paragraph,
VerticallyCenteredContainer,
} from "./styles";
import { useLiveQuery } from "dexie-react-hooks";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import subscriptionManager from "../app/SubscriptionManager";
@ -40,38 +45,64 @@ import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import logoOutline from "../img/ntfy-outline.svg";
import AttachmentIcon from "./AttachmentIcon";
import {Trans, useTranslation} from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
const Notifications = (props) => {
if (props.mode === "all") {
return (props.subscriptions) ? <AllSubscriptions subscriptions={props.subscriptions}/> : <Loading/>;
return props.subscriptions ? (
<AllSubscriptions subscriptions={props.subscriptions} />
) : (
<Loading />
);
}
return (props.subscription) ? <SingleSubscription subscription={props.subscription}/> : <Loading/>;
}
return props.subscription ? (
<SingleSubscription subscription={props.subscription} />
) : (
<Loading />
);
};
const AllSubscriptions = (props) => {
const subscriptions = props.subscriptions;
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
const notifications = useLiveQuery(
() => subscriptionManager.getAllNotifications(),
[],
);
if (notifications === null || notifications === undefined) {
return <Loading/>;
return <Loading />;
} else if (subscriptions.length === 0) {
return <NoSubscriptions/>;
return <NoSubscriptions />;
} 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 subscription = props.subscription;
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
const notifications = useLiveQuery(
() => subscriptionManager.getNotifications(subscription.id),
[subscription],
);
if (notifications === null || notifications === undefined) {
return <Loading/>;
return <Loading />;
} 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 { t } = useTranslation();
@ -85,13 +116,13 @@ const NotificationList = (props) => {
return () => {
setMaxCount(pageSize);
document.getElementById("main").scrollTo(0, 0);
}
};
}, [props.id]);
return (
<InfiniteScroll
dataLength={count}
next={() => setMaxCount(prev => prev + pageSize)}
next={() => setMaxCount((prev) => prev + pageSize)}
hasMore={count < notifications.length}
loader={<>Loading ...</>}
scrollThreshold={0.7}
@ -103,16 +134,17 @@ const NotificationList = (props) => {
aria-label={t("notifications_list")}
sx={{
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}>
{notifications.slice(0, count).map(notification =>
{notifications.slice(0, count).map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onShowSnack={() => setSnackOpen(true)}
/>)}
/>
))}
<Snackbar
open={snackOpen}
autoHideDuration={3000}
@ -123,7 +155,7 @@ const NotificationList = (props) => {
</Container>
</InfiniteScroll>
);
}
};
const NotificationItem = (props) => {
const { t } = useTranslation();
@ -131,81 +163,138 @@ const NotificationItem = (props) => {
const attachment = notification.attachment;
const date = formatShortDateTime(notification.time);
const otherTags = unmatchedTags(notification.tags);
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
const handleDelete = async () => {
console.log(`[Notifications] Deleting notification ${notification.id}`);
await subscriptionManager.deleteNotification(notification.id)
}
await subscriptionManager.deleteNotification(notification.id);
};
const handleMarkRead = async () => {
console.log(`[Notifications] Marking notification ${notification.id} as read`);
await subscriptionManager.markNotificationRead(notification.id)
}
console.log(
`[Notifications] Marking notification ${notification.id} as read`,
);
await subscriptionManager.markNotificationRead(notification.id);
};
const handleCopy = (s) => {
navigator.clipboard.writeText(s);
props.onShowSnack();
};
const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000;
const expired =
attachment && attachment.expires && attachment.expires < Date.now() / 1000;
const hasAttachmentActions = attachment && !expired;
const hasClickAction = notification.click;
const hasUserActions = notification.actions && notification.actions.length > 0;
const hasUserActions =
notification.actions && notification.actions.length > 0;
const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
return (
<Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
<Card
sx={{ minWidth: 275, padding: 1 }}
role="listitem"
aria-label={t("notifications_list_item")}
>
<CardContent>
<Tooltip title={t("notifications_delete")} enterDelay={500}>
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}>
<IconButton
onClick={handleDelete}
sx={{ float: "right", marginRight: -1, marginTop: -1 }}
aria-label={t("notifications_delete")}
>
<CloseIcon />
</IconButton>
</Tooltip>
{notification.new === 1 &&
{notification.new === 1 && (
<Tooltip title={t("notifications_mark_read")} enterDelay={500}>
<IconButton onClick={handleMarkRead} sx={{ float: 'right', marginRight: -0.5, marginTop: -1 }} aria-label={t("notifications_mark_read")}>
<IconButton
onClick={handleMarkRead}
sx={{ float: "right", marginRight: -0.5, marginTop: -1 }}
aria-label={t("notifications_mark_read")}
>
<CheckIcon />
</IconButton>
</Tooltip>}
</Tooltip>
)}
<Typography sx={{ fontSize: 14 }} color="text.secondary">
{date}
{[1,2,4,5].includes(notification.priority) &&
{[1, 2, 4, 5].includes(notification.priority) && (
<img
src={priorityFiles[notification.priority]}
alt={t("notifications_priority_x", { priority: notification.priority})}
style={{ verticalAlign: 'bottom' }}
/>}
{notification.new === 1 &&
<svg style={{ width: '8px', height: '8px', marginLeft: '4px' }} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-label={t("notifications_new_indicator")}>
<circle cx="50" cy="50" r="50" fill="#338574"/>
</svg>}
alt={t("notifications_priority_x", {
priority: notification.priority,
})}
style={{ verticalAlign: "bottom" }}
/>
)}
{notification.new === 1 && (
<svg
style={{ width: "8px", height: "8px", marginLeft: "4px" }}
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
aria-label={t("notifications_new_indicator")}
>
<circle cx="50" cy="50" r="50" fill="#338574" />
</svg>
)}
</Typography>
{notification.title && <Typography variant="h5" component="div" role="rowheader">{formatTitle(notification)}</Typography>}
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>
{autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
{notification.title && (
<Typography variant="h5" component="div" role="rowheader">
{formatTitle(notification)}
</Typography>
{attachment && <Attachment attachment={attachment}/>}
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">{t("notifications_tags")}: {tags}</Typography>}
)}
<Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
{autolink(
maybeAppendActionErrors(formatMessage(notification), notification),
)}
</Typography>
{attachment && <Attachment attachment={attachment} />}
{tags && (
<Typography sx={{ fontSize: 14 }} color="text.secondary">
{t("notifications_tags")}: {tags}
</Typography>
)}
</CardContent>
{showActions &&
<CardActions sx={{paddingTop: 0}}>
{hasAttachmentActions && <>
{showActions && (
<CardActions sx={{ paddingTop: 0 }}>
{hasAttachmentActions && (
<>
<Tooltip title={t("notifications_attachment_copy_url_title")}>
<Button onClick={() => handleCopy(attachment.url)}>{t("notifications_attachment_copy_url_button")}</Button>
<Button onClick={() => handleCopy(attachment.url)}>
{t("notifications_attachment_copy_url_button")}
</Button>
</Tooltip>
<Tooltip title={t("notifications_attachment_open_title", { url: attachment.url })}>
<Button onClick={() => openUrl(attachment.url)}>{t("notifications_attachment_open_button")}</Button>
<Tooltip
title={t("notifications_attachment_open_title", {
url: attachment.url,
})}
>
<Button onClick={() => openUrl(attachment.url)}>
{t("notifications_attachment_open_button")}
</Button>
</Tooltip>
</>}
{hasClickAction && <>
</>
)}
{hasClickAction && (
<>
<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 title={t("notifications_actions_open_url_title", { url: notification.click })}>
<Button onClick={() => openUrl(notification.click)}>{t("notifications_click_open_button")}</Button>
<Tooltip
title={t("notifications_actions_open_url_title", {
url: notification.click,
})}
>
<Button onClick={() => openUrl(notification.click)}>
{t("notifications_click_open_button")}
</Button>
</Tooltip>
</>}
{hasUserActions && <UserActions notification={notification}/>}
</CardActions>}
</>
)}
{hasUserActions && <UserActions notification={notification} />}
</CardActions>
)}
</Card>
);
}
};
/**
* 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
*/
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) {
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}</>;
};
@ -226,19 +327,20 @@ const priorityFiles = {
1: priority1,
2: priority2,
4: priority4,
5: priority5
5: priority5,
};
const Attachment = (props) => {
const { t } = useTranslation();
const attachment = props.attachment;
const expired = attachment.expires && attachment.expires < Date.now()/1000;
const expires = attachment.expires && attachment.expires > Date.now()/1000;
const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
const expired = attachment.expires && attachment.expires < Date.now() / 1000;
const expires = attachment.expires && attachment.expires > Date.now() / 1000;
const displayableImage =
!expired && attachment.type && attachment.type.startsWith("image/");
// Unexpired image
if (displayableImage) {
return <Image attachment={attachment}/>;
return <Image attachment={attachment} />;
}
// Anything else: Show box
@ -247,25 +349,40 @@ const Attachment = (props) => {
infos.push(formatBytes(attachment.size));
}
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) {
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) {
return (
<Box sx={{
display: 'flex',
alignItems: 'center',
<Box
sx={{
display: "flex",
alignItems: "center",
marginTop: 2,
padding: 1,
borderRadius: '4px',
}}>
<AttachmentIcon type={attachment.type}/>
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
borderRadius: "4px",
}}
>
<AttachmentIcon type={attachment.type} />
<Typography
variant="body2"
sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}
>
<b>{attachment.name}</b>
{maybeInfoText}
</Typography>
@ -275,26 +392,31 @@ const Attachment = (props) => {
// Not expired
return (
<ButtonBase sx={{
<ButtonBase
sx={{
marginTop: 2,
}}>
}}
>
<Link
href={attachment.url}
target="_blank"
rel="noopener"
underline="none"
sx={{
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
padding: 1,
borderRadius: '4px',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.05)'
}
borderRadius: "4px",
"&:hover": {
backgroundColor: "rgba(0, 0, 0, 0.05)",
},
}}
>
<AttachmentIcon type={attachment.type}/>
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
<AttachmentIcon type={attachment.type} />
<Typography
variant="body2"
sx={{ marginLeft: 1, textAlign: "left", color: "text.primary" }}
>
<b>{attachment.name}</b>
{maybeInfoText}
</Typography>
@ -316,12 +438,12 @@ const Image = (props) => {
onClick={() => setOpen(true)}
sx={{
marginTop: 2,
borderRadius: '4px',
borderRadius: "4px",
boxShadow: 2,
width: 1,
maxHeight: '400px',
objectFit: 'cover',
cursor: 'pointer'
maxHeight: "400px",
objectFit: "cover",
cursor: "pointer",
}}
/>
<Modal
@ -338,10 +460,10 @@ const Image = (props) => {
sx={{
maxWidth: 1,
maxHeight: 1,
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
padding: 4,
}}
/>
@ -349,12 +471,19 @@ const Image = (props) => {
</Modal>
</>
);
}
};
const UserActions = (props) => {
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") {
return (
<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>
);
} else if (action.action === "view") {
return (
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
<Tooltip
title={t("notifications_actions_open_url_title", { url: action.url })}
>
<Button
onClick={() => openUrl(action.url)}
aria-label={t("notifications_actions_open_url_title", { url: action.url })}
>{action.label}</Button>
aria-label={t("notifications_actions_open_url_title", {
url: action.url,
})}
>
{action.label}
</Button>
</Tooltip>
);
} else if (action.action === "http") {
const method = action.method ?? "POST";
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
const label =
action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
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
onClick={() => performHttpAction(notification, action)}
aria-label={t("notifications_actions_http_request_title", { method: method, url: action.url })}
>{label}</Button>
aria-label={t("notifications_actions_http_request_title", {
method: method,
url: action.url,
})}
>
{label}
</Button>
</Tooltip>
);
}
@ -401,30 +554,40 @@ const performHttpAction = async (notification, action) => {
headers: action.headers ?? {},
// This must not null-coalesce to a non nullish value. Otherwise, the fetch API
// will reject it for "having a body"
body: action.body
body: action.body,
});
console.log(`[Notifications] HTTP user action response`, response);
const success = response.status >= 200 && response.status <= 299;
if (success) {
updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null);
} else {
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`);
updateActionStatus(
notification,
action,
ACTION_PROGRESS_FAILED,
`${action.label}: Unexpected response HTTP ${response.status}`,
);
}
} catch (e) {
console.log(`[Notifications] HTTP action failed`, e);
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`);
updateActionStatus(
notification,
action,
ACTION_PROGRESS_FAILED,
`${action.label}: ${e} Check developer console for details.`,
);
}
};
const updateActionStatus = (notification, action, progress, error) => {
notification.actions = notification.actions.map(a => {
notification.actions = notification.actions.map((a) => {
if (a.id !== action.id) {
return a;
}
return { ...a, progress: progress, error: error };
});
subscriptionManager.updateNotification(notification);
}
};
const ACTION_PROGRESS_ONGOING = 1;
const ACTION_PROGRESS_SUCCESS = 2;
@ -433,29 +596,34 @@ const ACTION_PROGRESS_FAILED = 3;
const ACTION_LABEL_SUFFIX = {
[ACTION_PROGRESS_ONGOING]: " …",
[ACTION_PROGRESS_SUCCESS]: " ✔",
[ACTION_PROGRESS_FAILED]: " ❌"
[ACTION_PROGRESS_FAILED]: " ❌",
};
const NoNotifications = (props) => {
const { t } = useTranslation();
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
const shortUrl = topicShortUrl(
props.subscription.baseUrl,
props.subscription.topic,
);
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
<img
src={logoOutline}
height="64"
width="64"
alt={t("action_bar_logo_alt")}
/>
<br />
{t("notifications_none_for_topic_title")}
</Typography>
<Paragraph>{t("notifications_none_for_topic_description")}</Paragraph>
<Paragraph>
{t("notifications_none_for_topic_description")}
{t("notifications_example")}:<br />
<tt>$ curl -d "Hi" {shortUrl}</tt>
</Paragraph>
<Paragraph>
{t("notifications_example")}:<br/>
<tt>
$ curl -d "Hi" {shortUrl}
</tt>
</Paragraph>
<Paragraph>
<ForMoreDetails/>
<ForMoreDetails />
</Paragraph>
</VerticallyCenteredContainer>
);
@ -468,20 +636,22 @@ const NoNotificationsWithoutSubscription = (props) => {
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
<img
src={logoOutline}
height="64"
width="64"
alt={t("action_bar_logo_alt")}
/>
<br />
{t("notifications_none_for_any_title")}
</Typography>
<Paragraph>{t("notifications_none_for_any_description")}</Paragraph>
<Paragraph>
{t("notifications_none_for_any_description")}
{t("notifications_example")}:<br />
<tt>$ curl -d "Hi" {shortUrl}</tt>
</Paragraph>
<Paragraph>
{t("notifications_example")}:<br/>
<tt>
$ curl -d "Hi" {shortUrl}
</tt>
</Paragraph>
<Paragraph>
<ForMoreDetails/>
<ForMoreDetails />
</Paragraph>
</VerticallyCenteredContainer>
);
@ -492,16 +662,22 @@ const NoSubscriptions = () => {
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
<img
src={logoOutline}
height="64"
width="64"
alt={t("action_bar_logo_alt")}
/>
<br />
{t("notifications_no_subscriptions_title")}
</Typography>
<Paragraph>
{t("notifications_no_subscriptions_description", {
linktext: t("nav_button_subscribe")
linktext: t("nav_button_subscribe"),
})}
</Paragraph>
<Paragraph>
<ForMoreDetails/>
<ForMoreDetails />
</Paragraph>
</VerticallyCenteredContainer>
);
@ -512,8 +688,12 @@ const ForMoreDetails = () => {
<Trans
i18nKey="notifications_more_details"
components={{
websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener"/>,
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/>
websiteLink: (
<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();
return (
<VerticallyCenteredContainer>
<Typography variant="h5" color="text.secondary" align="center" sx={{ paddingBottom: 1 }}>
<CircularProgress disableShrink sx={{marginBottom: 1}}/><br />
<Typography
variant="h5"
color="text.secondary"
align="center"
sx={{ paddingBottom: 1 }}
>
<CircularProgress disableShrink sx={{ marginBottom: 1 }} />
<br />
{t("notifications_loading")}
</Typography>
</VerticallyCenteredContainer>

View file

@ -1,5 +1,5 @@
import * as React from 'react';
import {useEffect, useState} from 'react';
import * as React from "react";
import { useEffect, useState } from "react";
import {
CardActions,
CardContent,
@ -11,37 +11,37 @@ import {
TableCell,
TableHead,
TableRow,
useMediaQuery
useMediaQuery,
} from "@mui/material";
import Typography from "@mui/material/Typography";
import prefs from "../app/Prefs";
import {Paragraph} from "./styles";
import EditIcon from '@mui/icons-material/Edit';
import { Paragraph } from "./styles";
import EditIcon from "@mui/icons-material/Edit";
import CloseIcon from "@mui/icons-material/Close";
import IconButton from "@mui/material/IconButton";
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import Container from "@mui/material/Container";
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
import Card from "@mui/material/Card";
import Button from "@mui/material/Button";
import {useLiveQuery} from "dexie-react-hooks";
import { useLiveQuery } from "dexie-react-hooks";
import theme from "./theme";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import userManager from "../app/UserManager";
import {playSound, shuffle, sounds, validUrl} from "../app/utils";
import {useTranslation} from "react-i18next";
import { playSound, shuffle, sounds, validUrl } from "../app/utils";
import { useTranslation } from "react-i18next";
const Preferences = () => {
return (
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
<Container maxWidth="md" sx={{ marginTop: 3, marginBottom: 3 }}>
<Stack spacing={3}>
<Notifications/>
<Appearance/>
<Users/>
<Notifications />
<Appearance />
<Users />
</Stack>
</Container>
);
@ -50,14 +50,14 @@ const Preferences = () => {
const Notifications = () => {
const { t } = useTranslation();
return (
<Card sx={{p: 3}} aria-label={t("prefs_notifications_title")}>
<Typography variant="h5" sx={{marginBottom: 2}}>
<Card sx={{ p: 3 }} aria-label={t("prefs_notifications_title")}>
<Typography variant="h5" sx={{ marginBottom: 2 }}>
{t("prefs_notifications_title")}
</Typography>
<PrefGroup>
<Sound/>
<MinPriority/>
<DeleteAfter/>
<Sound />
<MinPriority />
<DeleteAfter />
</PrefGroup>
</Card>
);
@ -69,7 +69,7 @@ const Sound = () => {
const sound = useLiveQuery(async () => prefs.sound());
const handleChange = async (ev) => {
await prefs.setSound(ev.target.value);
}
};
if (!sound) {
return null; // While loading
}
@ -77,23 +77,43 @@ const Sound = () => {
if (sound === "none") {
description = t("prefs_notifications_sound_description_none");
} else {
description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label });
description = t("prefs_notifications_sound_description_some", {
sound: sounds[sound].label,
});
}
return (
<Pref labelId={labelId} title={t("prefs_notifications_sound_title")} description={description}>
<div style={{ display: 'flex', width: '100%' }}>
<Pref
labelId={labelId}
title={t("prefs_notifications_sound_title")}
description={description}
>
<div style={{ display: "flex", width: "100%" }}>
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
<Select value={sound} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem>
{Object.entries(sounds).map(s => <MenuItem key={s[0]} value={s[0]}>{s[1].label}</MenuItem>)}
<Select
value={sound}
onChange={handleChange}
aria-labelledby={labelId}
>
<MenuItem value={"none"}>
{t("prefs_notifications_sound_no_sound")}
</MenuItem>
{Object.entries(sounds).map((s) => (
<MenuItem key={s[0]} value={s[0]}>
{s[1].label}
</MenuItem>
))}
</Select>
</FormControl>
<IconButton onClick={() => playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}>
<IconButton
onClick={() => playSound(sound)}
disabled={sound === "none"}
aria-label={t("prefs_notifications_sound_play")}
>
<PlayArrowIcon />
</IconButton>
</div>
</Pref>
)
);
};
const MinPriority = () => {
@ -102,7 +122,7 @@ const MinPriority = () => {
const minPriority = useLiveQuery(async () => prefs.minPriority());
const handleChange = async (ev) => {
await prefs.setMinPriority(ev.target.value);
}
};
if (!minPriority) {
return null; // While loading
}
@ -111,32 +131,53 @@ const MinPriority = () => {
2: t("priority_low"),
3: t("priority_default"),
4: t("priority_high"),
5: t("priority_max")
}
5: t("priority_max"),
};
let description;
if (minPriority === 1) {
description = t("prefs_notifications_min_priority_description_any");
} else if (minPriority === 5) {
description = t("prefs_notifications_min_priority_description_max");
} else {
description = t("prefs_notifications_min_priority_description_x_or_higher", {
description = t(
"prefs_notifications_min_priority_description_x_or_higher",
{
number: minPriority,
name: priorities[minPriority]
});
name: priorities[minPriority],
},
);
}
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 }}>
<Select value={minPriority} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem>
<MenuItem value={2}>{t("prefs_notifications_min_priority_low_and_higher")}</MenuItem>
<MenuItem value={3}>{t("prefs_notifications_min_priority_default_and_higher")}</MenuItem>
<MenuItem value={4}>{t("prefs_notifications_min_priority_high_and_higher")}</MenuItem>
<MenuItem value={5}>{t("prefs_notifications_min_priority_max_only")}</MenuItem>
<Select
value={minPriority}
onChange={handleChange}
aria-labelledby={labelId}
>
<MenuItem value={1}>
{t("prefs_notifications_min_priority_any")}
</MenuItem>
<MenuItem value={2}>
{t("prefs_notifications_min_priority_low_and_higher")}
</MenuItem>
<MenuItem value={3}>
{t("prefs_notifications_min_priority_default_and_higher")}
</MenuItem>
<MenuItem value={4}>
{t("prefs_notifications_min_priority_high_and_higher")}
</MenuItem>
<MenuItem value={5}>
{t("prefs_notifications_min_priority_max_only")}
</MenuItem>
</Select>
</FormControl>
</Pref>
)
);
};
const DeleteAfter = () => {
@ -145,40 +186,60 @@ const DeleteAfter = () => {
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
const handleChange = async (ev) => {
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
}
const description = (() => {
switch (deleteAfter) {
case 0: return t("prefs_notifications_delete_after_never_description");
case 10800: return t("prefs_notifications_delete_after_three_hours_description");
case 86400: return t("prefs_notifications_delete_after_one_day_description");
case 604800: return t("prefs_notifications_delete_after_one_week_description");
case 2592000: return t("prefs_notifications_delete_after_one_month_description");
case 0:
return t("prefs_notifications_delete_after_never_description");
case 10800:
return t("prefs_notifications_delete_after_three_hours_description");
case 86400:
return t("prefs_notifications_delete_after_one_day_description");
case 604800:
return t("prefs_notifications_delete_after_one_week_description");
case 2592000:
return t("prefs_notifications_delete_after_one_month_description");
}
})();
return (
<Pref labelId={labelId} title={t("prefs_notifications_delete_after_title")} description={description}>
<Pref
labelId={labelId}
title={t("prefs_notifications_delete_after_title")}
description={description}
>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={deleteAfter} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem>
<MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem>
<MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem>
<MenuItem value={604800}>{t("prefs_notifications_delete_after_one_week")}</MenuItem>
<MenuItem value={2592000}>{t("prefs_notifications_delete_after_one_month")}</MenuItem>
<Select
value={deleteAfter}
onChange={handleChange}
aria-labelledby={labelId}
>
<MenuItem value={0}>
{t("prefs_notifications_delete_after_never")}
</MenuItem>
<MenuItem value={10800}>
{t("prefs_notifications_delete_after_three_hours")}
</MenuItem>
<MenuItem value={86400}>
{t("prefs_notifications_delete_after_one_day")}
</MenuItem>
<MenuItem value={604800}>
{t("prefs_notifications_delete_after_one_week")}
</MenuItem>
<MenuItem value={2592000}>
{t("prefs_notifications_delete_after_one_month")}
</MenuItem>
</Select>
</FormControl>
</Pref>
)
);
};
const PrefGroup = (props) => {
return (
<div role="table">
{props.children}
</div>
)
return <div role="table">{props.children}</div>;
};
const Pref = (props) => {
@ -197,23 +258,29 @@ const Pref = (props) => {
id={props.labelId}
aria-label={props.title}
style={{
flex: '1 0 40%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
paddingRight: '30px'
flex: "1 0 40%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
paddingRight: "30px",
}}
>
<div><b>{props.title}</b></div>
{props.description && <div><em>{props.description}</em></div>}
<div>
<b>{props.title}</b>
</div>
{props.description && (
<div>
<em>{props.description}</em>
</div>
)}
</div>
<div
role="cell"
style={{
flex: '1 0 calc(60% - 50px)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
flex: "1 0 calc(60% - 50px)",
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
{props.children}
@ -228,7 +295,7 @@ const Users = () => {
const [dialogOpen, setDialogOpen] = useState(false);
const users = useLiveQuery(() => userManager.all());
const handleAddClick = () => {
setDialogKey(prev => prev+1);
setDialogKey((prev) => prev + 1);
setDialogOpen(true);
};
const handleDialogCancel = () => {
@ -238,7 +305,9 @@ const Users = () => {
setDialogOpen(false);
try {
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) {
console.log(`[Preferences] Error adding user.`, e);
}
@ -246,13 +315,11 @@ const Users = () => {
return (
<Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
<CardContent sx={{ paddingBottom: 1 }}>
<Typography variant="h5" sx={{marginBottom: 2}}>
<Typography variant="h5" sx={{ marginBottom: 2 }}>
{t("prefs_users_title")}
</Typography>
<Paragraph>
{t("prefs_users_description")}
</Paragraph>
{users?.length > 0 && <UserTable users={users}/>}
<Paragraph>{t("prefs_users_description")}</Paragraph>
{users?.length > 0 && <UserTable users={users} />}
</CardContent>
<CardActions>
<Button onClick={handleAddClick}>{t("prefs_users_add_button")}</Button>
@ -275,7 +342,7 @@ const UserTable = (props) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogUser, setDialogUser] = useState(null);
const handleEditClick = (user) => {
setDialogKey(prev => prev+1);
setDialogKey((prev) => prev + 1);
setDialogUser(user);
setDialogOpen(true);
};
@ -286,7 +353,9 @@ const UserTable = (props) => {
setDialogOpen(false);
try {
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) {
console.log(`[Preferences] Error updating user.`, e);
}
@ -294,7 +363,9 @@ const UserTable = (props) => {
const handleDeleteClick = async (user) => {
try {
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) {
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")}>
<TableHead>
<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/>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{props.users?.map(user => (
{props.users?.map((user) => (
<TableRow
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 aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
<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">
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
<EditIcon/>
<IconButton
onClick={() => handleEditClick(user)}
aria-label={t("prefs_users_edit_button")}
>
<EditIcon />
</IconButton>
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
<IconButton
onClick={() => handleDeleteClick(user)}
aria-label={t("prefs_users_delete_button")}
>
<CloseIcon />
</IconButton>
</TableCell>
@ -344,25 +432,29 @@ const UserDialog = (props) => {
const [baseUrl, setBaseUrl] = useState("");
const [username, setUsername] = 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 addButtonEnabled = (() => {
if (editMode) {
return username.length > 0 && password.length > 0;
}
const baseUrlValid = validUrl(baseUrl);
const baseUrlExists = props.users?.map(user => user.baseUrl).includes(baseUrl);
return baseUrlValid
&& !baseUrlExists
&& username.length > 0
&& password.length > 0;
const baseUrlExists = props.users
?.map((user) => user.baseUrl)
.includes(baseUrl);
return (
baseUrlValid &&
!baseUrlExists &&
username.length > 0 &&
password.length > 0
);
})();
const handleSubmit = async () => {
props.onSubmit({
baseUrl: baseUrl,
username: username,
password: password
})
password: password,
});
};
useEffect(() => {
if (editMode) {
@ -373,20 +465,26 @@ const UserDialog = (props) => {
}, [editMode, props.user]);
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")}</DialogTitle>
<DialogTitle>
{editMode
? t("prefs_users_dialog_title_edit")
: t("prefs_users_dialog_title_add")}
</DialogTitle>
<DialogContent>
{!editMode && <TextField
{!editMode && (
<TextField
autoFocus
margin="dense"
id="baseUrl"
label={t("prefs_users_dialog_base_url_label")}
aria-label={t("prefs_users_dialog_base_url_label")}
value={baseUrl}
onChange={ev => setBaseUrl(ev.target.value)}
onChange={(ev) => setBaseUrl(ev.target.value)}
type="url"
fullWidth
variant="standard"
/>}
/>
)}
<TextField
autoFocus={editMode}
margin="dense"
@ -394,7 +492,7 @@ const UserDialog = (props) => {
label={t("prefs_users_dialog_username_label")}
aria-label={t("prefs_users_dialog_username_label")}
value={username}
onChange={ev => setUsername(ev.target.value)}
onChange={(ev) => setUsername(ev.target.value)}
type="text"
fullWidth
variant="standard"
@ -406,14 +504,20 @@ const UserDialog = (props) => {
aria-label={t("prefs_users_dialog_password_label")}
type="password"
value={password}
onChange={ev => setPassword(ev.target.value)}
onChange={(ev) => setPassword(ev.target.value)}
fullWidth
variant="standard"
/>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>{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>
<Button onClick={props.onCancel}>
{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>
</Dialog>
);
@ -422,12 +526,12 @@ const UserDialog = (props) => {
const Appearance = () => {
const { t } = useTranslation();
return (
<Card sx={{p: 3}} aria-label={t("prefs_appearance_title")}>
<Typography variant="h5" sx={{marginBottom: 2}}>
<Card sx={{ p: 3 }} aria-label={t("prefs_appearance_title")}>
<Typography variant="h5" sx={{ marginBottom: 2 }}>
{t("prefs_appearance_title")}
</Typography>
<PrefGroup>
<Language/>
<Language />
</PrefGroup>
</Card>
);
@ -436,8 +540,28 @@ const Appearance = () => {
const Language = () => {
const { t, i18n } = useTranslation();
const labelId = "prefLanguage";
const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇵🇱", "🇺🇦", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
const randomFlags = shuffle([
"🇬🇧",
"🇺🇸",
"🇪🇸",
"🇫🇷",
"🇧🇬",
"🇨🇿",
"🇩🇪",
"🇵🇱",
"🇺🇦",
"🇨🇳",
"🇮🇹",
"🇭🇺",
"🇧🇷",
"🇳🇱",
"🇮🇩",
"🇯🇵",
"🇷🇺",
"🇹🇷",
]).slice(0, 3);
const title =
t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
const lang = i18n.language ?? "en";
// Remember: Flags are not languages. Don't put flags next to the language in the list.
@ -447,7 +571,11 @@ const Language = () => {
return (
<Pref labelId={labelId} title={title}>
<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="id">Bahasa Indonesia</MenuItem>
<MenuItem value="bg">Български</MenuItem>
@ -470,7 +598,7 @@ const Language = () => {
</Select>
</FormControl>
</Pref>
)
);
};
export default Preferences;

View file

@ -1,8 +1,17 @@
import * as React from 'react';
import {useEffect, useRef, useState} from 'react';
import {NotificationItem} from "./Notifications";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { NotificationItem } from "./Notifications";
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 priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
@ -15,17 +24,25 @@ import DialogContent from "@mui/material/DialogContent";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
import {Close} from "@mui/icons-material";
import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
import { Close } from "@mui/icons-material";
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 AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter";
import api from "../app/Api";
import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker";
import {Trans, useTranslation} from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
const PublishDialog = (props) => {
const { t } = useTranslation();
@ -65,10 +82,10 @@ const PublishDialog = (props) => {
const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
const open = !!props.openMode;
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
useEffect(() => {
window.addEventListener('dragenter', () => {
window.addEventListener("dragenter", () => {
props.onDragEnter();
setDropZone(true);
});
@ -92,7 +109,7 @@ const PublishDialog = (props) => {
const updateBaseUrl = (newVal) => {
if (validUrl(newVal)) {
setBaseUrl(newVal.replace(/\/$/, '')); // strip traililng slash after https?://
setBaseUrl(newVal.replace(/\/$/, "")); // strip traililng slash after https?://
} else {
setBaseUrl(newVal);
}
@ -125,19 +142,24 @@ const PublishDialog = (props) => {
url.searchParams.append("delay", delay.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 {
const user = await userManager.get(baseUrl);
const headers = maybeWithBasicAuth({}, user);
const progressFn = (ev) => {
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),
total: formatBytes(ev.total),
percent: Math.round(ev.loaded * 100.0 / ev.total)
}));
percent: Math.round((ev.loaded * 100.0) / ev.total),
}),
);
} else {
setStatus(t("publish_dialog_progress_uploading"));
}
@ -152,7 +174,11 @@ const PublishDialog = (props) => {
setActiveRequest(null);
}
} catch (e) {
setStatus(<Typography sx={{color: 'error.main', maxWidth: "400px"}}>{e}</Typography>);
setStatus(
<Typography sx={{ color: "error.main", maxWidth: "400px" }}>
{e}
</Typography>,
);
setActiveRequest(null);
}
};
@ -162,17 +188,28 @@ const PublishDialog = (props) => {
const stats = await api.userStats(baseUrl);
const fileSizeLimit = stats.attachmentFileSizeLimit ?? 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;
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),
remainingBytes: formatBytes(remainingBytes)
}));
remainingBytes: formatBytes(remainingBytes),
}),
);
} 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) {
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("");
} catch (e) {
@ -214,7 +251,7 @@ const PublishDialog = (props) => {
};
const handleEmojiPick = (emoji) => {
setTags(tags => (tags.trim()) ? `${tags.trim()}, ${emoji}` : emoji);
setTags((tags) => (tags.trim() ? `${tags.trim()}, ${emoji}` : emoji));
};
const handleEmojiClose = () => {
@ -226,37 +263,55 @@ const PublishDialog = (props) => {
2: { label: t("publish_dialog_priority_low"), file: priority2 },
3: { label: t("publish_dialog_priority_default"), file: priority3 },
4: { label: t("publish_dialog_priority_high"), file: priority4 },
5: { label: t("publish_dialog_priority_max"), file: priority5 }
5: { label: t("publish_dialog_priority_max"), file: priority5 },
};
return (
<>
{dropZone && <DropArea
{dropZone && (
<DropArea
onDrop={handleAttachFileDrop}
onDragLeave={handleAttachFileDragLeave}/>
}
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{(baseUrl && topic) ? t("publish_dialog_title_topic", { topic: topicShortUrl(baseUrl, topic) }) : t("publish_dialog_title_no_topic")}</DialogTitle>
onDragLeave={handleAttachFileDragLeave}
/>
)}
<Dialog
maxWidth="md"
open={open}
onClose={props.onCancel}
fullScreen={fullScreen}
>
<DialogTitle>
{baseUrl && topic
? t("publish_dialog_title_topic", {
topic: topicShortUrl(baseUrl, topic),
})
: t("publish_dialog_title_no_topic")}
</DialogTitle>
<DialogContent>
{dropZone && <DropBox/>}
{showTopicUrl &&
<ClosableRow closable={!!props.baseUrl && !!props.topic} disabled={disabled} closeLabel={t("publish_dialog_topic_reset")} onClose={() => {
{dropZone && <DropBox />}
{showTopicUrl && (
<ClosableRow
closable={!!props.baseUrl && !!props.topic}
disabled={disabled}
closeLabel={t("publish_dialog_topic_reset")}
onClose={() => {
setBaseUrl(props.baseUrl);
setTopic(props.topic);
setShowTopicUrl(false);
}}>
}}
>
<TextField
margin="dense"
label={t("publish_dialog_base_url_label")}
placeholder={t("publish_dialog_base_url_placeholder")}
value={baseUrl}
onChange={ev => updateBaseUrl(ev.target.value)}
onChange={(ev) => updateBaseUrl(ev.target.value)}
disabled={disabled}
type="url"
variant="standard"
sx={{flexGrow: 1, marginRight: 1}}
sx={{ flexGrow: 1, marginRight: 1 }}
inputProps={{
"aria-label": t("publish_dialog_base_url_label")
"aria-label": t("publish_dialog_base_url_label"),
}}
/>
<TextField
@ -264,30 +319,30 @@ const PublishDialog = (props) => {
label={t("publish_dialog_topic_label")}
placeholder={t("publish_dialog_topic_placeholder")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
onChange={(ev) => setTopic(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
autoFocus={!messageFocused}
sx={{flexGrow: 1}}
sx={{ flexGrow: 1 }}
inputProps={{
"aria-label": t("publish_dialog_topic_label")
"aria-label": t("publish_dialog_topic_label"),
}}
/>
</ClosableRow>
}
)}
<TextField
margin="dense"
label={t("publish_dialog_title_label")}
placeholder={t("publish_dialog_title_placeholder")}
value={title}
onChange={ev => setTitle(ev.target.value)}
onChange={(ev) => setTitle(ev.target.value)}
disabled={disabled}
type="text"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("publish_dialog_title_label")
"aria-label": t("publish_dialog_title_label"),
}}
/>
<TextField
@ -295,7 +350,7 @@ const PublishDialog = (props) => {
label={t("publish_dialog_message_label")}
placeholder={t("publish_dialog_message_placeholder")}
value={message}
onChange={ev => setMessage(ev.target.value)}
onChange={(ev) => setMessage(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
@ -304,38 +359,42 @@ const PublishDialog = (props) => {
fullWidth
multiline
inputProps={{
"aria-label": t("publish_dialog_message_label")
"aria-label": t("publish_dialog_message_label"),
}}
/>
<div style={{display: 'flex'}}>
<div style={{ display: "flex" }}>
<EmojiPicker
anchorEl={emojiPickerAnchorEl}
onEmojiPick={handleEmojiPick}
onClose={handleEmojiClose}
/>
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
<InsertEmoticonIcon/>
<DialogIconButton
disabled={disabled}
onClick={handleEmojiClick}
aria-label={t("publish_dialog_emoji_picker_show")}
>
<InsertEmoticonIcon />
</DialogIconButton>
<TextField
margin="dense"
label={t("publish_dialog_tags_label")}
placeholder={t("publish_dialog_tags_placeholder")}
value={tags}
onChange={ev => setTags(ev.target.value)}
onChange={(ev) => setTags(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
sx={{flexGrow: 1, marginRight: 1}}
sx={{ flexGrow: 1, marginRight: 1 }}
inputProps={{
"aria-label": t("publish_dialog_tags_label")
"aria-label": t("publish_dialog_tags_label"),
}}
/>
<FormControl
variant="standard"
margin="dense"
sx={{minWidth: 170, maxWidth: 300, flexGrow: 1}}
sx={{ minWidth: 170, maxWidth: 300, flexGrow: 1 }}
>
<InputLabel/>
<InputLabel />
<Select
label={t("publish_dialog_priority_label")}
margin="dense"
@ -343,75 +402,99 @@ const PublishDialog = (props) => {
onChange={(ev) => setPriority(ev.target.value)}
disabled={disabled}
inputProps={{
"aria-label": t("publish_dialog_priority_label")
"aria-label": t("publish_dialog_priority_label"),
}}
>
{[5,4,3,2,1].map(priority =>
<MenuItem key={`priorityMenuItem${priority}`} value={priority} aria-label={t("notifications_priority_x", { priority: priority })}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<img src={priorities[priority].file} style={{marginRight: "8px"}} alt={t("notifications_priority_x", { priority: priority })}/>
{[5, 4, 3, 2, 1].map((priority) => (
<MenuItem
key={`priorityMenuItem${priority}`}
value={priority}
aria-label={t("notifications_priority_x", {
priority: priority,
})}
>
<div style={{ display: "flex", alignItems: "center" }}>
<img
src={priorities[priority].file}
style={{ marginRight: "8px" }}
alt={t("notifications_priority_x", {
priority: priority,
})}
/>
<div>{priorities[priority].label}</div>
</div>
</MenuItem>
)}
))}
</Select>
</FormControl>
</div>
{showClickUrl &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_click_reset")} onClose={() => {
{showClickUrl && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_click_reset")}
onClose={() => {
setClickUrl("");
setShowClickUrl(false);
}}>
}}
>
<TextField
margin="dense"
label={t("publish_dialog_click_label")}
placeholder={t("publish_dialog_click_placeholder")}
value={clickUrl}
onChange={ev => setClickUrl(ev.target.value)}
onChange={(ev) => setClickUrl(ev.target.value)}
disabled={disabled}
type="url"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("publish_dialog_click_label")
"aria-label": t("publish_dialog_click_label"),
}}
/>
</ClosableRow>
}
{showEmail &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_email_reset")} onClose={() => {
)}
{showEmail && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_email_reset")}
onClose={() => {
setEmail("");
setShowEmail(false);
}}>
}}
>
<TextField
margin="dense"
label={t("publish_dialog_email_label")}
placeholder={t("publish_dialog_email_placeholder")}
value={email}
onChange={ev => setEmail(ev.target.value)}
onChange={(ev) => setEmail(ev.target.value)}
disabled={disabled}
type="email"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_email_label")
"aria-label": t("publish_dialog_email_label"),
}}
/>
</ClosableRow>
}
{showAttachUrl &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
)}
{showAttachUrl && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_attach_reset")}
onClose={() => {
setAttachUrl("");
setFilename("");
setFilenameEdited(false);
setShowAttachUrl(false);
}}>
}}
>
<TextField
margin="dense"
label={t("publish_dialog_attach_label")}
placeholder={t("publish_dialog_attach_placeholder")}
value={attachUrl}
onChange={ev => {
onChange={(ev) => {
const url = ev.target.value;
setAttachUrl(url);
if (!filenameEdited) {
@ -419,7 +502,7 @@ const PublishDialog = (props) => {
const u = new URL(url);
const parts = u.pathname.split("/");
if (parts.length > 0) {
setFilename(parts[parts.length-1]);
setFilename(parts[parts.length - 1]);
}
} catch (e) {
// Do nothing
@ -429,9 +512,9 @@ const PublishDialog = (props) => {
disabled={disabled}
type="url"
variant="standard"
sx={{flexGrow: 5, marginRight: 1}}
sx={{ flexGrow: 5, marginRight: 1 }}
inputProps={{
"aria-label": t("publish_dialog_attach_label")
"aria-label": t("publish_dialog_attach_label"),
}}
/>
<TextField
@ -439,28 +522,29 @@ const PublishDialog = (props) => {
label={t("publish_dialog_filename_label")}
placeholder={t("publish_dialog_filename_placeholder")}
value={filename}
onChange={ev => {
onChange={(ev) => {
setFilename(ev.target.value);
setFilenameEdited(true);
}}
disabled={disabled}
type="text"
variant="standard"
sx={{flexGrow: 1}}
sx={{ flexGrow: 1 }}
inputProps={{
"aria-label": t("publish_dialog_filename_label")
"aria-label": t("publish_dialog_filename_label"),
}}
/>
</ClosableRow>
}
)}
<input
type="file"
ref={attachFileInput}
onChange={handleAttachFileChanged}
style={{ display: 'none' }}
style={{ display: "none" }}
aria-hidden={true}
/>
{showAttachFile && <AttachmentBox
{showAttachFile && (
<AttachmentBox
file={attachFile}
filename={filename}
disabled={disabled}
@ -471,72 +555,149 @@ const PublishDialog = (props) => {
setAttachFileError("");
setFilename("");
}}
/>}
{showDelay &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_delay_reset")} onClose={() => {
/>
)}
{showDelay && (
<ClosableRow
disabled={disabled}
closeLabel={t("publish_dialog_delay_reset")}
onClose={() => {
setDelay("");
setShowDelay(false);
}}>
}}
>
<TextField
margin="dense"
label={t("publish_dialog_delay_label")}
placeholder={t("publish_dialog_delay_placeholder", {
unixTimestamp: "1649029748",
relativeTime: "30m",
naturalLanguage: "tomorrow, 9am"
naturalLanguage: "tomorrow, 9am",
})}
value={delay}
onChange={ev => setDelay(ev.target.value)}
onChange={(ev) => setDelay(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_delay_label")
"aria-label": t("publish_dialog_delay_label"),
}}
/>
</ClosableRow>
}
<Typography variant="body1" sx={{marginTop: 2, marginBottom: 1}}>
)}
<Typography variant="body1" sx={{ marginTop: 2, marginBottom: 1 }}>
{t("publish_dialog_other_features")}
</Typography>
<div>
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!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}}/>}
{!showClickUrl && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_click_label")}
aria-label={t("publish_dialog_chip_click_label")}
onClick={() => setShowClickUrl(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!showEmail && (
<Chip
clickable
disabled={disabled}
label={t("publish_dialog_chip_email_label")}
aria-label={t("publish_dialog_chip_email_label")}
onClick={() => setShowEmail(true)}
sx={{ marginRight: 1, marginBottom: 1 }}
/>
)}
{!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>
<Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}>
<Typography variant="body1" sx={{ marginTop: 1, marginBottom: 1 }}>
<Trans
i18nKey="publish_dialog_details_examples_description"
components={{
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/>
docsLink: (
<Link
href="https://ntfy.sh/docs"
target="_blank"
rel="noopener"
/>
),
}}
/>
</Typography>
</DialogContent>
<DialogFooter status={status}>
{activeRequest && <Button onClick={() => activeRequest.abort()}>{t("publish_dialog_button_cancel_sending")}</Button>}
{!activeRequest &&
{activeRequest && (
<Button onClick={() => activeRequest.abort()}>
{t("publish_dialog_button_cancel_sending")}
</Button>
)}
{!activeRequest && (
<>
<FormControlLabel
label={t("publish_dialog_checkbox_publish_another")}
sx={{marginRight: 2}}
sx={{ marginRight: 2 }}
control={
<Checkbox
size="small"
checked={publishAnother}
onChange={(ev) => setPublishAnother(ev.target.checked)}
inputProps={{
"aria-label": t("publish_dialog_checkbox_publish_another")
}} />
} />
<Button onClick={props.onClose}>{t("publish_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>{t("publish_dialog_button_send")}</Button>
</>
"aria-label": t(
"publish_dialog_checkbox_publish_another",
),
}}
/>
}
/>
<Button onClick={props.onClose}>
{t("publish_dialog_button_cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>
{t("publish_dialog_button_send")}
</Button>
</>
)}
</DialogFooter>
</Dialog>
</>
@ -545,22 +706,27 @@ const PublishDialog = (props) => {
const Row = (props) => {
return (
<div style={{display: 'flex'}} role="row">
<div style={{ display: "flex" }} role="row">
{props.children}
</div>
);
};
const ClosableRow = (props) => {
const closable = (props.hasOwnProperty("closable")) ? props.closable : true;
const closable = props.hasOwnProperty("closable") ? props.closable : true;
return (
<Row>
{props.children}
{closable &&
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={props.closeLabel}>
<Close/>
{closable && (
<DialogIconButton
disabled={props.disabled}
onClick={props.onClose}
sx={{ marginLeft: "6px" }}
aria-label={props.closeLabel}
>
<Close />
</DialogIconButton>
}
)}
</Row>
);
};
@ -572,7 +738,7 @@ const DialogIconButton = (props) => {
color="inherit"
size="large"
edge="start"
sx={{height: "45px", marginTop: "17px", ...sx}}
sx={{ height: "45px", marginTop: "17px", ...sx }}
onClick={props.onClick}
disabled={props.disabled}
aria-label={props["aria-label"]}
@ -587,17 +753,19 @@ const AttachmentBox = (props) => {
const file = props.file;
return (
<>
<Typography variant="body1" sx={{marginTop: 2}}>
<Typography variant="body1" sx={{ marginTop: 2 }}>
{t("publish_dialog_attached_file_title")}
</Typography>
<Box sx={{
display: 'flex',
alignItems: 'center',
<Box
sx={{
display: "flex",
alignItems: "center",
padding: 0.5,
borderRadius: '4px',
}}>
<AttachmentIcon type={file.type}/>
<Box sx={{ marginLeft: 1, textAlign: 'left' }}>
borderRadius: "4px",
}}
>
<AttachmentIcon type={file.type} />
<Box sx={{ marginLeft: 1, textAlign: "left" }}>
<ExpandingTextField
minWidth={140}
variant="body2"
@ -606,18 +774,28 @@ const AttachmentBox = (props) => {
onChange={(ev) => props.onChangeFilename(ev.target.value)}
disabled={props.disabled}
/>
<br/>
<Typography variant="body2" sx={{ color: 'text.primary' }}>
<br />
<Typography variant="body2" sx={{ color: "text.primary" }}>
{formatBytes(file.size)}
{props.error &&
<Typography component="span" sx={{ color: 'error.main' }} aria-live="polite">
{" "}({props.error})
{props.error && (
<Typography
component="span"
sx={{ color: "error.main" }}
aria-live="polite"
>
{" "}
({props.error})
</Typography>
}
)}
</Typography>
</Box>
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={t("publish_dialog_attached_file_remove")}>
<Close/>
<DialogIconButton
disabled={props.disabled}
onClick={props.onClose}
sx={{ marginLeft: "6px" }}
aria-label={t("publish_dialog_attached_file_remove")}
>
<Close />
</DialogIconButton>
</Box>
</>
@ -632,7 +810,9 @@ const ExpandingTextField = (props) => {
if (!boundingRect) {
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(() => {
setTextWidth(determineTextWidth() + 5);
@ -644,7 +824,7 @@ const ExpandingTextField = (props) => {
component="span"
variant={props.variant}
aria-hidden={true}
sx={{position: "absolute", left: "-200%"}}
sx={{ position: "absolute", left: "-200%" }}
>
{props.value}
</Typography>
@ -656,15 +836,17 @@ const ExpandingTextField = (props) => {
type="text"
variant="standard"
sx={{ width: `${textWidth}px`, borderBottom: "none" }}
InputProps={{ style: { fontSize: theme.typography[props.variant].fontSize } }}
InputProps={{
style: { fontSize: theme.typography[props.variant].fontSize },
}}
inputProps={{
style: { paddingBottom: 0, paddingTop: 0 },
"aria-label": props.placeholder
"aria-label": props.placeholder,
}}
disabled={props.disabled}
/>
</>
)
);
};
const DropArea = (props) => {
@ -672,14 +854,14 @@ const DropArea = (props) => {
// This is where we could disallow certain files to be dragged in.
// For now we allow all files.
ev.dataTransfer.dropEffect = 'copy';
ev.dataTransfer.dropEffect = "copy";
ev.preventDefault();
};
return (
<Box
sx={{
position: 'absolute',
position: "absolute",
left: 0,
top: 0,
right: 0,
@ -697,35 +879,39 @@ const DropArea = (props) => {
const DropBox = () => {
const { t } = useTranslation();
return (
<Box sx={{
position: 'absolute',
<Box
sx={{
position: "absolute",
left: 0,
top: 0,
right: 0,
bottom: 0,
zIndex: 10000,
backgroundColor: "#ffffffbb"
}}>
backgroundColor: "#ffffffbb",
}}
>
<Box
sx={{
position: 'absolute',
border: '3px dashed #ccc',
borderRadius: '5px',
position: "absolute",
border: "3px dashed #ccc",
borderRadius: "5px",
left: "40px",
top: "40px",
right: "40px",
bottom: "40px",
zIndex: 10001,
display: 'flex',
display: "flex",
justifyContent: "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>
);
}
};
PublishDialog.OPEN_MODE_DEFAULT = "default";
PublishDialog.OPEN_MODE_DRAG = "drag";

View file

@ -1,20 +1,30 @@
import * as React from 'react';
import {useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material";
import * as React from "react";
import { useState } from "react";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import {
Autocomplete,
Checkbox,
FormControlLabel,
useMediaQuery,
} from "@mui/material";
import theme from "./theme";
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 subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller";
import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
import { useTranslation } from "react-i18next";
const publicBaseUrl = "https://ntfy.sh";
@ -22,16 +32,17 @@ const SubscribeDialog = (props) => {
const [baseUrl, setBaseUrl] = useState("");
const [topic, setTopic] = useState("");
const [showLoginPage, setShowLoginPage] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSuccess = async () => {
const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin;
const actualBaseUrl = baseUrl ? baseUrl : window.location.origin;
const subscription = await subscriptionManager.add(actualBaseUrl, topic);
poller.pollInBackground(subscription); // Dangle!
props.onSuccess(subscription);
}
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
{!showLoginPage && <SubscribePage
{!showLoginPage && (
<SubscribePage
baseUrl={baseUrl}
setBaseUrl={setBaseUrl}
topic={topic}
@ -40,13 +51,16 @@ const SubscribeDialog = (props) => {
onCancel={props.onCancel}
onNeedsLogin={() => setShowLoginPage(true)}
onSuccess={handleSuccess}
/>}
{showLoginPage && <LoginPage
/>
)}
{showLoginPage && (
<LoginPage
baseUrl={baseUrl}
topic={topic}
onBack={() => setShowLoginPage(false)}
onSuccess={handleSuccess}
/>}
/>
)}
</Dialog>
);
};
@ -55,26 +69,45 @@ const SubscribePage = (props) => {
const { t } = useTranslation();
const [anotherServerVisible, setAnotherServerVisible] = useState(false);
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 existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic));
const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)]))
.filter(s => s !== window.location.origin);
const existingTopicUrls = props.subscriptions.map((s) =>
topicUrl(s.baseUrl, s.topic),
);
const existingBaseUrls = Array.from(
new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)]),
).filter((s) => s !== window.location.origin);
const handleSubscribe = async () => {
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);
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) {
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));
setErrorText(
t("subscribe_dialog_error_user_not_authorized", {
username: username,
}),
);
return;
} else {
props.onNeedsLogin();
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();
};
const handleUseAnotherChanged = (e) => {
@ -83,16 +116,20 @@ const SubscribePage = (props) => {
};
const subscribeButtonEnabled = (() => {
if (anotherServerVisible) {
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
const isExistingTopicUrl = existingTopicUrls.includes(
topicUrl(baseUrl, topic),
);
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
} else {
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(window.location.origin, topic));
const isExistingTopicUrl = existingTopicUrls.includes(
topicUrl(window.location.origin, topic),
);
return validTopic(topic) && !isExistingTopicUrl;
}
})();
const updateBaseUrl = (ev, newVal) => {
if (validUrl(newVal)) {
props.setBaseUrl(newVal.replace(/\/$/, '')); // strip trailing slash after https?://
props.setBaseUrl(newVal.replace(/\/$/, "")); // strip trailing slash after https?://
} else {
props.setBaseUrl(newVal);
}
@ -104,56 +141,68 @@ const SubscribePage = (props) => {
<DialogContentText>
{t("subscribe_dialog_subscribe_description")}
</DialogContentText>
<div style={{display: 'flex'}} role="row">
<div style={{ display: "flex" }} role="row">
<TextField
autoFocus
margin="dense"
id="topic"
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
value={props.topic}
onChange={ev => props.setTopic(ev.target.value)}
onChange={(ev) => props.setTopic(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
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")}
</Button>
</div>
<FormControlLabel
sx={{pt: 1}}
sx={{ pt: 1 }}
control={
<Checkbox
onChange={handleUseAnotherChanged}
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")} />
{anotherServerVisible && <Autocomplete
label={t("subscribe_dialog_subscribe_use_another_label")}
/>
{anotherServerVisible && (
<Autocomplete
freeSolo
options={existingBaseUrls}
sx={{ maxWidth: 400 }}
inputValue={props.baseUrl}
onInputChange={updateBaseUrl}
renderInput={ (params) =>
renderInput={(params) => (
<TextField
{...params}
placeholder={window.location.origin}
variant="standard"
aria-label={t("subscribe_dialog_subscribe_base_url_label")}
/>
}
/>}
)}
/>
)}
</DialogContent>
<DialogFooter status={errorText}>
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>{t("subscribe_dialog_subscribe_button_subscribe")}</Button>
<Button onClick={props.onCancel}>
{t("subscribe_dialog_subscribe_button_cancel")}
</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>
{t("subscribe_dialog_subscribe_button_subscribe")}
</Button>
</DialogFooter>
</>
);
@ -164,17 +213,29 @@ const LoginPage = (props) => {
const [username, setUsername] = useState("");
const [password, setPassword] = 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 handleLogin = async () => {
const user = {baseUrl, username, password};
const user = { baseUrl, username, password };
const success = await api.auth(baseUrl, topic, user);
if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));
console.log(
`[SubscribeDialog] Login to ${topicUrl(
baseUrl,
topic,
)} failed for user ${username}`,
);
setErrorText(
t("subscribe_dialog_error_user_not_authorized", { username: username }),
);
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);
props.onSuccess();
};
@ -191,12 +252,12 @@ const LoginPage = (props) => {
id="username"
label={t("subscribe_dialog_login_username_label")}
value={username}
onChange={ev => setUsername(ev.target.value)}
onChange={(ev) => setUsername(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_username_label")
"aria-label": t("subscribe_dialog_login_username_label"),
}}
/>
<TextField
@ -205,17 +266,21 @@ const LoginPage = (props) => {
label={t("subscribe_dialog_login_password_label")}
type="password"
value={password}
onChange={ev => setPassword(ev.target.value)}
onChange={(ev) => setPassword(ev.target.value)}
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_password_label")
"aria-label": t("subscribe_dialog_login_password_label"),
}}
/>
</DialogContent>
<DialogFooter status={errorText}>
<Button onClick={props.onBack}>{t("subscribe_dialog_login_button_back")}</Button>
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
<Button onClick={props.onBack}>
{t("subscribe_dialog_login_button_back")}
</Button>
<Button onClick={handleLogin}>
{t("subscribe_dialog_login_button_login")}
</Button>
</DialogFooter>
</>
);

View file

@ -1,30 +1,37 @@
import * as React from 'react';
import {useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material";
import * as React from "react";
import { useState } from "react";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import {
Autocomplete,
Checkbox,
FormControlLabel,
useMediaQuery,
} from "@mui/material";
import theme from "./theme";
import api from "../app/Api";
import {topicUrl, validTopic, validUrl} from "../app/utils";
import { topicUrl, validTopic, validUrl } from "../app/utils";
import userManager from "../app/UserManager";
import subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller";
import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
import { useTranslation } from "react-i18next";
const SubscriptionSettingsDialog = (props) => {
const { t } = useTranslation();
const subscription = props.subscription;
const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const [displayName, setDisplayName] = useState(
subscription.displayName ?? "",
);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSave = async () => {
await subscriptionManager.setDisplayName(subscription.id, displayName);
props.onClose();
}
};
return (
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("subscription_settings_dialog_title")}</DialogTitle>
@ -36,21 +43,29 @@ const SubscriptionSettingsDialog = (props) => {
autoFocus
margin="dense"
id="topic"
placeholder={t("subscription_settings_dialog_display_name_placeholder")}
placeholder={t(
"subscription_settings_dialog_display_name_placeholder",
)}
value={displayName}
onChange={ev => setDisplayName(ev.target.value)}
onChange={(ev) => setDisplayName(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
maxLength: 64,
"aria-label": t("subscription_settings_dialog_display_name_placeholder")
"aria-label": t(
"subscription_settings_dialog_display_name_placeholder",
),
}}
/>
</DialogContent>
<DialogFooter>
<Button onClick={props.onClose}>{t("subscription_settings_button_cancel")}</Button>
<Button onClick={handleSave}>{t("subscription_settings_button_save")}</Button>
<Button onClick={props.onClose}>
{t("subscription_settings_button_cancel")}
</Button>
<Button onClick={handleSave}>
{t("subscription_settings_button_save")}
</Button>
</DialogFooter>
</Dialog>
);

View file

@ -1,7 +1,7 @@
import {useNavigate, useParams} from "react-router-dom";
import {useEffect, useState} from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useState } from "react";
import subscriptionManager from "../app/SubscriptionManager";
import {disallowedTopic, expandSecureUrl, topicUrl} from "../app/utils";
import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils";
import notifier from "../app/Notifier";
import routes from "./routes";
import connectionManager from "../app/ConnectionManager";
@ -16,12 +16,21 @@ import pruner from "../app/Pruner";
export const useConnectionListeners = (subscriptions, users) => {
const navigate = useNavigate();
useEffect(() => {
useEffect(
() => {
const handleNotification = async (subscriptionId, notification) => {
const added = await subscriptionManager.addNotification(subscriptionId, notification);
const added = await subscriptionManager.addNotification(
subscriptionId,
notification,
);
if (added) {
const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription));
await notifier.notify(subscriptionId, notification, defaultClickAction)
const defaultClickAction = (subscription) =>
navigate(routes.forSubscription(subscription));
await notifier.notify(
subscriptionId,
notification,
defaultClickAction,
);
}
};
connectionManager.registerStateListener(subscriptionManager.updateState);
@ -29,11 +38,11 @@ export const useConnectionListeners = (subscriptions, users) => {
return () => {
connectionManager.resetStateListener();
connectionManager.resetNotificationListener();
}
};
},
// We have to disable dep checking for "navigate". This is fine, it never changes.
// eslint-disable-next-line
[]
[],
);
useEffect(() => {
@ -55,12 +64,20 @@ export const useAutoSubscribe = (subscriptions, selected) => {
return;
}
setHasRun(true);
const eligible = params.topic && !selected && !disallowedTopic(params.topic);
const eligible =
params.topic && !selected && !disallowedTopic(params.topic);
if (eligible) {
const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : window.location.origin;
console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
const baseUrl = params.baseUrl
? expandSecureUrl(params.baseUrl)
: window.location.origin;
console.log(
`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`,
);
(async () => {
const subscription = await subscriptionManager.add(baseUrl, params.topic);
const subscription = await subscriptionManager.add(
baseUrl,
params.topic,
);
poller.pollInBackground(subscription); // Dangle!
})();
}
@ -77,4 +94,4 @@ export const useBackgroundProcesses = () => {
poller.startWorker();
pruner.startWorker();
}, []);
}
};

View file

@ -1,7 +1,7 @@
import i18n from 'i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import i18n from "i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
// Translations using i18next
// - Options: https://www.i18next.com/overview/configuration-options
@ -16,14 +16,14 @@ i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
fallbackLng: "en",
debug: true,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
backend: {
loadPath: '/static/langs/{{lng}}.json',
}
loadPath: "/static/langs/{{lng}}.json",
},
});
export default i18n;

View file

@ -1,5 +1,5 @@
import config from "../app/config";
import {shortUrl} from "../app/utils";
import { shortUrl } from "../app/utils";
const routes = {
root: config.appRoot,
@ -11,7 +11,7 @@ const routes = {
return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;
}
return `/${subscription.topic}`;
}
},
};
export default routes;

View file

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

View file

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

View file

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