Compare commits

..

49 commits

Author SHA1 Message Date
8eecd3c72a
Dockerfile*: use my images
Signed-off-by: Vincent Batts <vbatts@hashbangbash.com>
2023-06-03 15:33:31 -04:00
77f5dd705c
*: python pip in debian bookworm/testing needs venv
When testing this build using the upcoming debian bookworm/testing, the
python pip v3.11 fails to install requirements that may clobber the host
install packages. It prints out that venv must be used.

This change works fine on the current debian bullseye, and will continue
to work once folks switch to the upcoming debian bookworm release.

Signed-off-by: Vincent Batts <vbatts@hashbangbash.com>
2023-06-03 15:29:51 -04:00
binwiederhier
f58c1e4c84 Fix previous fix 2023-06-01 16:01:39 -04:00
binwiederhier
dc8932cd95 Fix segault in ntfy pub 2023-06-01 14:08:51 -04:00
binwiederhier
04cc71af90 .gitignore 2023-06-01 13:56:32 -04:00
binwiederhier
44d189179d Merge branch 'main' of github.com:binwiederhier/ntfy 2023-05-31 15:36:21 -04:00
binwiederhier
d084a415f3 Do not forward UP messages to upstream 2023-05-31 15:36:02 -04:00
Philipp C. Heckel
953efbee47
Merge pull request #759 from nimbleghost/fix-race-condition
Fix account sync race condition
2023-05-31 14:21:14 -04:00
binwiederhier
807f24723d Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-05-31 13:57:10 -04:00
nimbleghost
453bf435b0 Fix account sync race condition 2023-05-31 19:37:29 +02:00
arjan-s
ca25b80bfb
Translated using Weblate (Dutch)
Currently translated at 100.0% (382 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/nl/
2023-05-31 09:52:20 +02:00
Shjosan
afb585e6fd
Translated using Weblate (Swedish)
Currently translated at 100.0% (382 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/sv/
2023-05-29 00:51:22 +02:00
Andrew
2e7f474775
Translated using Weblate (Ukrainian)
Currently translated at 100.0% (382 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2023-05-29 00:51:21 +02:00
gallegonovato
bd39072596
Translated using Weblate (Spanish)
Currently translated at 100.0% (382 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2023-05-29 00:51:20 +02:00
binwiederhier
b222541ea8 Merge branch 'main' of github.com:binwiederhier/ntfy 2023-05-26 21:06:14 -04:00
Philipp C. Heckel
1368dae849
Merge pull request #754 from nimbleghost/docker-local-build
Add a way to use Docker for building everything
2023-05-26 19:14:35 -04:00
iTentalce
578ccf1643
Translated using Weblate (Czech)
Currently translated at 96.0% (367 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/cs/
2023-05-27 00:51:08 +02:00
Linerly
217c660ba0
Translated using Weblate (Indonesian)
Currently translated at 100.0% (382 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2023-05-27 00:51:08 +02:00
nimbleghost
11f8984127 Add a way to use Docker for building everything
I’d like to test #751 on my own instance, but installing all the build
dependencies on my server isn’t ideal - having this script in the repo
would make it possible to simply point my compose file to the git repo
and have it build the Linux binary itself.

Note that it uses a somewhat “inefficient” builder step, i.e. not
combining steps together to reduce layers, as it uses a multi-stage
build to have a lean final image. This makes it easier to re-build if
something needs to change, as the cache is used more optimally.

For example, if only some go files change, most of the build is already
cached and only the go step gets re-run.

The more “efficient” builder step would look like this, but would have
to build the docs, web app and go CLI for any change in any file:

```Dockerfile
FROM golang:1.19-bullseye as builder

RUN apt-get update && \
    curl -fsSL https://deb.nodesource.com/setup_18.x | bash && \
    apt-get install -y \
    build-essential \
    nodejs \
    python3-pip

WORKDIR /app
ADD . .

RUN make web docs cli-linux-server
```
2023-05-26 22:22:21 +02:00
nimbleghost
232c889ce3 Use apt-get in makefile
`apt` is for interactive shell usage, using it in a script results in a
warning as the CLI interface is not stable

> WARNING: apt does not have a stable CLI interface.
> Use with caution in scripts.
2023-05-26 21:14:59 +02:00
Kalil Maciel
02524ca101
Translated using Weblate (Portuguese)
Currently translated at 59.8% (228 of 381 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2023-05-25 15:24:44 +02:00
Rogelio Dominguez
38bd4f3ce3
Translated using Weblate (Spanish)
Currently translated at 100.0% (381 of 381 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2023-05-25 15:24:44 +02:00
Philipp C. Heckel
3101f93d22
Merge pull request #750 from nimbleghost/web-improvements
Fix suppressed eslint issues
2023-05-25 08:03:03 -04:00
nimbleghost
da17e4ee8a Make small code style improvements 2023-05-25 07:17:05 +02:00
nimbleghost
d178be7576 Fix param reassignment issue 2023-05-25 07:17:05 +02:00
nimbleghost
4d90e32fe9 Use es6 destructuring swap for shuffling 2023-05-25 07:17:05 +02:00
nimbleghost
9056d68fc9 Make async for loops performant using Promise.all 2023-05-25 07:17:05 +02:00
binwiederhier
c16da26780 Release notes 2023-05-24 22:28:26 -04:00
binwiederhier
c50633d990 Deps 2023-05-24 22:18:10 -04:00
binwiederhier
517341b5d7 Re-add @emotion due to build errors 2023-05-24 22:15:46 -04:00
binwiederhier
e1dd0c64e2 Merge branch 'main' into switch-to-vite 2023-05-24 21:59:14 -04:00
binwiederhier
e7bf165934 Formatting 2023-05-24 21:59:04 -04:00
binwiederhier
a90bd4cd06 Formatting, npm update 2023-05-24 21:44:12 -04:00
binwiederhier
d1e59fe08c Merge branch 'main' into switch-to-vite 2023-05-24 21:37:28 -04:00
binwiederhier
6bb5274d83 Release notes 2023-05-24 21:34:25 -04:00
binwiederhier
b7c121e78e Revert inputProps things 2023-05-24 21:32:15 -04:00
binwiederhier
1251a4adab Merge branch 'main' into add-eslint 2023-05-24 21:31:53 -04:00
binwiederhier
4cacc02520 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-05-24 20:37:47 -04:00
nimbleghost
d625a003b8 Use esm mui imports for Vite compatibility
See: https://github.com/mui/material-ui/issues/31835#issuecomment-1153393901
2023-05-24 22:16:10 +02:00
nimbleghost
e21327cec5 Add vite
Changes according to Vite defaults:

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

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

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

- Reorder declarations to fix `no-use-before-define`
- Rename parameters for `no-shadow`
- Remove unused parameters, functions, imports
- Switch from `++` and `—` to `+= 1` and `-= 1` for `no-unary`
- Use object spreading instead of parameter reassignment in auth utils
- Use `window.location` instead of `location` global
- Use inline JSX strings instead of unescaped values
-
2023-05-24 12:58:48 +02:00
nimbleghost
8319f1cf26 Run eslint autofixes 2023-05-24 12:51:53 +02:00
nimbleghost
f558b4dbe9 Add .jsx filename extension
(This is also required for Vite later)
2023-05-24 12:51:53 +02:00
nimbleghost
d7eb1206fe Add eslint with eslint-config-airbnb 2023-05-24 12:51:52 +02:00
69 changed files with 2019 additions and 15826 deletions

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
dist
*/node_modules
Dockerfile*

View file

@ -5,3 +5,7 @@
c87549e71a10bc789eac8036078228f06e515a8e c87549e71a10bc789eac8036078228f06e515a8e
ca5d736a7169eb6b4b0d849e061d5bf9565dcc53 ca5d736a7169eb6b4b0d849e061d5bf9565dcc53
2e27f58963feb9e4d1c573d4745d07770777fa7d 2e27f58963feb9e4d1c573d4745d07770777fa7d
# Run eslint (https://github.com/binwiederhier/ntfy/pull/748)
f558b4dbe9bb5b9e0e87fada1215de2558353173
8319f1cf26113167fb29fe12edaff5db74caf35f

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
dist/ dist/
dev-dist/
build/ build/
.idea/ .idea/
.vscode/ .vscode/

View file

@ -1,4 +1,4 @@
FROM alpine FROM r.batts.cloud/debian:testing
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com" LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
LABEL org.opencontainers.image.url="https://ntfy.sh/" LABEL org.opencontainers.image.url="https://ntfy.sh/"

54
Dockerfile-build Normal file
View file

@ -0,0 +1,54 @@
FROM r.batts.cloud/golang:1.19 as builder
ARG VERSION=dev
ARG COMMIT=unknown
RUN apt-get update
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash
RUN apt-get install -y \
build-essential \
nodejs \
python3-pip \
python3-venv
WORKDIR /app
ADD Makefile .
# docs
ADD ./requirements.txt .
RUN make docs-deps
ADD ./mkdocs.yml .
ADD ./docs ./docs
RUN make docs-build
# web
ADD ./web/package.json ./web/package-lock.json ./web/
RUN make web-deps
ADD ./web ./web
RUN make web-build
# cli & server
ADD go.mod go.sum main.go ./
ADD ./client ./client
ADD ./cmd ./cmd
ADD ./log ./log
ADD ./server ./server
ADD ./user ./user
ADD ./util ./util
RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
FROM r.batts.cloud/debian:testing
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
LABEL org.opencontainers.image.url="https://ntfy.sh/"
LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/"
LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy"
LABEL org.opencontainers.image.vendor="Philipp C. Heckel"
LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
LABEL org.opencontainers.image.title="ntfy"
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy
EXPOSE 80/tcp
ENTRYPOINT ["ntfy"]

View file

@ -31,12 +31,16 @@ help:
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)" @echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)" @echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
@echo @echo
@echo "Build dev Docker:"
@echo " make docker-dev - Build client & server for current architecture using Docker only"
@echo
@echo "Build web app:" @echo "Build web app:"
@echo " make web - Build the web app" @echo " make web - Build the web app"
@echo " make web-deps - Install web app dependencies (npm install the universe)" @echo " make web-deps - Install web app dependencies (npm install the universe)"
@echo " make web-build - Actually build the web app" @echo " make web-build - Actually build the web app"
@echo " make web-format - Run prettier on the web app @echo " make web-lint - Run eslint on the web app"
@echo " make web-format-check - Run prettier on the web app, but don't change anything @echo " make web-format - Run prettier on the web app"
@echo " make web-format-check - Run prettier on the web app, but don't change anything"
@echo @echo
@echo "Build documentation:" @echo "Build documentation:"
@echo " make docs - Build the documentation" @echo " make docs - Build the documentation"
@ -82,23 +86,33 @@ build: web docs cli
update: web-deps-update cli-deps-update docs-deps-update update: web-deps-update cli-deps-update docs-deps-update
docker pull alpine docker pull alpine
docker-dev:
docker build \
--file ./Dockerfile-build \
--tag binwiederhier/ntfy:$(VERSION) \
--tag binwiederhier/ntfy:dev \
--build-arg VERSION=$(VERSION) \
--build-arg COMMIT=$(COMMIT) \
./
# Ubuntu-specific # Ubuntu-specific
build-deps-ubuntu: build-deps-ubuntu:
sudo apt update sudo apt-get update
sudo apt install -y \ sudo apt-get install -y \
curl \ curl \
gcc-aarch64-linux-gnu \ gcc-aarch64-linux-gnu \
gcc-arm-linux-gnueabi \ gcc-arm-linux-gnueabi \
jq jq
which pip3 || sudo apt install -y python3-pip which pip3 || sudo apt-get install -y python3-pip
# Documentation # Documentation
docs: docs-deps docs-build docs: docs-deps docs-build
docs-build: .PHONY docs-build: venv .PHONY
@if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \ @. venv/bin/activate && \
if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \
if which python3.8; then \ if which python3.8; then \
echo "python3.8 $(shell which mkdocs) build"; \ echo "python3.8 $(shell which mkdocs) build"; \
python3.8 $(shell which mkdocs) build; \ python3.8 $(shell which mkdocs) build; \
@ -111,10 +125,15 @@ docs-build: .PHONY
mkdocs build; \ mkdocs build; \
fi fi
docs-deps: .PHONY venv:
python3 -m venv ./venv
docs-deps: venv .PHONY
. venv/bin/activate && \
pip3 install -r requirements.txt pip3 install -r requirements.txt
docs-deps-update: .PHONY docs-deps-update: venv .PHONY
. venv/bin/activate && \
pip3 install -r requirements.txt --upgrade pip3 install -r requirements.txt --upgrade
@ -129,8 +148,7 @@ web-build:
&& rm -rf ../server/site \ && rm -rf ../server/site \
&& mv build ../server/site \ && mv build ../server/site \
&& rm \ && rm \
../server/site/config.js \ ../server/site/config.js
../server/site/asset-manifest.json
web-deps: web-deps:
cd web && npm install cd web && npm install
@ -145,6 +163,9 @@ web-format:
web-format-check: web-format-check:
cd web && npm run format:check cd web && npm run format:check
web-lint:
cd web && npm run lint
# Main server/client build # Main server/client build
cli: cli-deps cli: cli-deps
@ -233,7 +254,7 @@ cli-build-results:
# Test/check targets # Test/check targets
check: test web-format-check fmt-check vet lint staticcheck check: test web-format-check fmt-check vet web-lint lint staticcheck
test: .PHONY test: .PHONY
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')

View file

@ -11,23 +11,25 @@ import (
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"io" "io"
"net/http" "net/http"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
// Event type constants
const ( const (
MessageEvent = "message" // MessageEvent identifies a message event
KeepaliveEvent = "keepalive" MessageEvent = "message"
OpenEvent = "open"
PollRequestEvent = "poll_request"
) )
const ( const (
maxResponseBytes = 4096 maxResponseBytes = 4096
) )
var (
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // Same as in server/server.go
)
// Client is the ntfy client that can be used to publish and subscribe to ntfy topics // Client is the ntfy client that can be used to publish and subscribe to ntfy topics
type Client struct { type Client struct {
Messages chan *Message Messages chan *Message
@ -96,8 +98,14 @@ func (c *Client) Publish(topic, message string, options ...PublishOption) (*Mess
// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache, // To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
// WithNoFirebase, and the generic WithHeader. // WithNoFirebase, and the generic WithHeader.
func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) { func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {
topicURL := c.expandTopicURL(topic) topicURL, err := c.expandTopicURL(topic)
req, _ := http.NewRequest("POST", topicURL, body) if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", topicURL, body)
if err != nil {
return nil, err
}
for _, option := range options { for _, option := range options {
if err := option(req); err != nil { if err := option(req); err != nil {
return nil, err return nil, err
@ -133,11 +141,14 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
// By default, all messages will be returned, but you can change this behavior using a SubscribeOption. // By default, all messages will be returned, but you can change this behavior using a SubscribeOption.
// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam. // See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam.
func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) { func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) {
topicURL, err := c.expandTopicURL(topic)
if err != nil {
return nil, err
}
ctx := context.Background() ctx := context.Background()
messages := make([]*Message, 0) messages := make([]*Message, 0)
msgChan := make(chan *Message) msgChan := make(chan *Message)
errChan := make(chan error) errChan := make(chan error)
topicURL := c.expandTopicURL(topic)
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL)) log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
options = append(options, WithPoll()) options = append(options, WithPoll())
go func() { go func() {
@ -166,15 +177,18 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
// Example: // Example:
// //
// c := client.New(client.NewConfig()) // c := client.New(client.NewConfig())
// subscriptionID := c.Subscribe("mytopic") // subscriptionID, _ := c.Subscribe("mytopic")
// for m := range c.Messages { // for m := range c.Messages {
// fmt.Printf("New message: %s", m.Message) // fmt.Printf("New message: %s", m.Message)
// } // }
func (c *Client) Subscribe(topic string, options ...SubscribeOption) string { func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, error) {
topicURL, err := c.expandTopicURL(topic)
if err != nil {
return "", err
}
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
subscriptionID := util.RandomString(10) subscriptionID := util.RandomString(10)
topicURL := c.expandTopicURL(topic)
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL)) log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
c.subscriptions[subscriptionID] = &subscription{ c.subscriptions[subscriptionID] = &subscription{
@ -183,7 +197,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
cancel: cancel, cancel: cancel,
} }
go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...) go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...)
return subscriptionID return subscriptionID, nil
} }
// Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique // Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique
@ -199,31 +213,16 @@ func (c *Client) Unsubscribe(subscriptionID string) {
sub.cancel() sub.cancel()
} }
// UnsubscribeAll unsubscribes from a topic that has been previously subscribed with Subscribe. func (c *Client) expandTopicURL(topic string) (string, error) {
// If there are multiple subscriptions matching the topic, all of them are unsubscribed from.
//
// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
// config (e.g. mytopic -> https://ntfy.sh/mytopic).
func (c *Client) UnsubscribeAll(topic string) {
c.mu.Lock()
defer c.mu.Unlock()
topicURL := c.expandTopicURL(topic)
for _, sub := range c.subscriptions {
if sub.topicURL == topicURL {
delete(c.subscriptions, sub.ID)
sub.cancel()
}
}
}
func (c *Client) expandTopicURL(topic string) string {
if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") { if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") {
return topic return topic, nil
} else if strings.Contains(topic, "/") { } else if strings.Contains(topic, "/") {
return fmt.Sprintf("https://%s", topic) return fmt.Sprintf("https://%s", topic), nil
} }
return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic) if !topicRegex.MatchString(topic) {
return "", fmt.Errorf("invalid topic name: %s", topic)
}
return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic), nil
} }
func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) { func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) {

View file

@ -21,7 +21,7 @@ func TestClient_Publish_Subscribe(t *testing.T) {
defer test.StopServer(t, s, port) defer test.StopServer(t, s, port)
c := client.New(newTestConfig(port)) c := client.New(newTestConfig(port))
subscriptionID := c.Subscribe("mytopic") subscriptionID, _ := c.Subscribe("mytopic")
time.Sleep(time.Second) time.Sleep(time.Second)
msg, err := c.Publish("mytopic", "some message") msg, err := c.Publish("mytopic", "some message")

View file

@ -72,7 +72,7 @@ ntfy subscribe TOPIC COMMAND
$NTFY_TITLE $title, $t Message title $NTFY_TITLE $title, $t Message title
$NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max) $NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
$NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list) $NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
$NTFY_RAW $raw Raw JSON message $NTFY_RAW $raw Raw JSON message
Examples: Examples:
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
@ -194,7 +194,10 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
topicOptions = append(topicOptions, auth) topicOptions = append(topicOptions, auth)
} }
subscriptionID := cl.Subscribe(s.Topic, topicOptions...) subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...)
if err != nil {
return err
}
if s.Command != "" { if s.Command != "" {
cmds[subscriptionID] = s.Command cmds[subscriptionID] = s.Command
} else if conf.DefaultCommand != "" { } else if conf.DefaultCommand != "" {
@ -204,7 +207,10 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
} }
} }
if topic != "" { if topic != "" {
subscriptionID := cl.Subscribe(topic, options...) subscriptionID, err := cl.Subscribe(topic, options...)
if err != nil {
return err
}
cmds[subscriptionID] = command cmds[subscriptionID] = command
} }
for m := range cl.Messages { for m := range cl.Messages {

View file

@ -163,6 +163,15 @@ $ make release-snapshot
During development, you may want to be more picky and build only certain things. Here are a few examples. During development, you may want to be more picky and build only certain things. Here are a few examples.
### Build a Docker image only for Linux
This is useful to test the final build with web app, docs, and server without any dependencies locally
``` shell
$ make docker-dev
$ docker run --rm -p 80:80 binwiederhier/ntfy:dev serve
```
### Build the ntfy binary ### Build the ntfy binary
To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets: To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets:

View file

@ -1222,8 +1222,15 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
### ntfy server v2.6.0 (UNRELEASED) ### ntfy server v2.6.0 (UNRELEASED)
**Bug fixes + maintenance:** **Bug fixes:**
* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting) * Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting)
* Do not forward poll requests for UnifiedPush messages (no ticket, thanks to NoName for reporting)
* Fix `ntfy pub %` segfaulting ([#760](https://github.com/binwiederhier/ntfy/issues/760), thanks to [@clesmian](https://github.com/clesmian) for reporting)
**Maintenance:**
* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost)) * Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost))
* Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost)) * Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost))
* Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), thanks to [@nimbleghost](https://github.com/nimbleghost))
* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost))

View file

@ -760,7 +760,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
if s.config.TwilioAccount != "" && call != "" { if s.config.TwilioAccount != "" && call != "" {
go s.callPhone(v, r, m, call) go s.callPhone(v, r, m, call)
} }
if s.config.UpstreamBaseURL != "" { if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream
go s.forwardPollRequest(v, m) go s.forwardPollRequest(v, m)
} }
} else { } else {
@ -963,10 +963,6 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
} }
} }
contentType, markdown := readParam(r, "content-type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
if markdown || strings.ToLower(contentType) == "text/markdown" {
m.ContentType = "text/markdown"
}
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
if unifiedpush { if unifiedpush {
firebase = false firebase = false

View file

@ -144,18 +144,17 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
} }
if allowForward { if allowForward {
data = map[string]string{ data = map[string]string{
"id": m.ID, "id": m.ID,
"time": fmt.Sprintf("%d", m.Time), "time": fmt.Sprintf("%d", m.Time),
"event": m.Event, "event": m.Event,
"topic": m.Topic, "topic": m.Topic,
"priority": fmt.Sprintf("%d", m.Priority), "priority": fmt.Sprintf("%d", m.Priority),
"tags": strings.Join(m.Tags, ","), "tags": strings.Join(m.Tags, ","),
"click": m.Click, "click": m.Click,
"icon": m.Icon, "icon": m.Icon,
"title": m.Title, "title": m.Title,
"message": m.Message, "message": m.Message,
"content_type": m.ContentType, "encoding": m.Encoding,
"encoding": m.Encoding,
} }
if len(m.Actions) > 0 { if len(m.Actions) > 0 {
actions, err := json.Marshal(m.Actions) actions, err := json.Marshal(m.Actions)

View file

@ -219,7 +219,7 @@ func TestServer_StaticSites(t *testing.T) {
rr = request(t, s, "GET", "/mytopic", "", nil) rr = request(t, s, "GET", "/mytopic", "", nil)
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow"/>`) require.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow" />`)
rr = request(t, s, "GET", "/docs", "", nil) rr = request(t, s, "GET", "/docs", "", nil)
require.Equal(t, 301, rr.Code) require.Equal(t, 301, rr.Code)
@ -2559,6 +2559,29 @@ func TestServer_UpstreamBaseURL_With_Access_Token_Success(t *testing.T) {
}) })
} }
func TestServer_UpstreamBaseURL_DoNotForwardUnifiedPush(t *testing.T) {
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("UnifiedPush messages should not be forwarded")
}))
defer upstreamServer.Close()
c := newTestConfigWithAuthFile(t)
c.BaseURL = "http://myserver.internal"
c.UpstreamBaseURL = upstreamServer.URL
s := newTestServer(t, c)
// Send UP message, this should not forward to upstream server
response := request(t, s, "PUT", "/mytopic?up=1", `hi there`, nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.NotEmpty(t, m.ID)
require.Equal(t, "hi there", m.Message)
// Forwarding is done asynchronously, so wait a bit.
// This ensures that the t.Fatal above is actually not triggered.
time.Sleep(500 * time.Millisecond)
}
func newTestConfig(t *testing.T) *Config { func newTestConfig(t *testing.T) *Config {
conf := NewConfig() conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345" conf.BaseURL = "http://127.0.0.1:12345"

View file

@ -24,24 +24,23 @@ const (
// message represents a message published to a topic // message represents a message published to a topic
type message struct { type message struct {
ID string `json:"id"` // Random message ID ID string `json:"id"` // Random message ID
Time int64 `json:"time"` // Unix time in seconds Time int64 `json:"time"` // Unix time in seconds
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
Event string `json:"event"` // One of the above Event string `json:"event"` // One of the above
Topic string `json:"topic"` Topic string `json:"topic"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
Priority int `json:"priority,omitempty"` Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"` Click string `json:"click,omitempty"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
Actions []*action `json:"actions,omitempty"` Actions []*action `json:"actions,omitempty"`
Attachment *attachment `json:"attachment,omitempty"` Attachment *attachment `json:"attachment,omitempty"`
PollID string `json:"poll_id,omitempty"` PollID string `json:"poll_id,omitempty"`
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting User string `json:"-"` // Username of the uploader, used to associated attachments
User string `json:"-"` // Username of the uploader, used to associated attachments
} }
func (m *message) Context() log.Context { func (m *message) Context() log.Context {

1
web/.eslintignore Normal file
View file

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

37
web/.eslintrc Normal file
View file

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

View file

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

View file

@ -15,7 +15,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" /> <meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
<!-- Favicon, see favicon.io --> <!-- Favicon, see favicon.io -->
<link rel="icon" type="image/png" href="%PUBLIC_URL%/static/images/favicon.ico" /> <link rel="icon" type="image/png" href="/static/images/favicon.ico" />
<!-- Previews in Google, Slack, WhatsApp, etc. --> <!-- Previews in Google, Slack, WhatsApp, etc. -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
@ -26,15 +26,15 @@
property="og:description" property="og:description"
content="ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." content="ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy."
/> />
<meta property="og:image" content="%PUBLIC_URL%/static/images/ntfy.png" /> <meta property="og:image" content="/static/images/ntfy.png" />
<meta property="og:url" content="https://ntfy.sh" /> <meta property="og:url" content="https://ntfy.sh" />
<!-- Never index --> <!-- Never index -->
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<!-- Style overrides & fonts --> <!-- Style overrides & fonts -->
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/app.css" type="text/css" /> <link rel="stylesheet" href="/static/css/app.css" type="text/css" />
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css" /> <link rel="stylesheet" href="/static/css/fonts.css" type="text/css" />
</head> </head>
<body> <body>
<noscript> <noscript>
@ -43,6 +43,7 @@
subscribe. subscribe.
</noscript> </noscript>
<div id="root"></div> <div id="root"></div>
<script src="%PUBLIC_URL%/config.js"></script> <script src="/config.js"></script>
<script type="module" src="/src/index.jsx"></script>
</body> </body>
</html> </html>

15390
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,14 +3,16 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "react-scripts start", "start": "NODE_OPTIONS=\"--enable-source-maps\" vite",
"build": "react-scripts build", "build": "vite build",
"test": "react-scripts test", "serve": "vite preview",
"eject": "react-scripts eject",
"format": "prettier . --write", "format": "prettier . --write",
"format:check": "prettier . --check" "format:check": "prettier . --check",
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.4.2", "@mui/icons-material": "^5.4.2",
"@mui/material": "latest", "@mui/material": "latest",
"dexie": "^3.2.1", "dexie": "^3.2.1",
@ -29,8 +31,16 @@
"stacktrace-js": "^2.0.2" "stacktrace-js": "^2.0.2"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"eslint": "^8.41.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"react-scripts": "^5.0.0" "vite": "^4.3.8"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

View file

@ -355,5 +355,15 @@
"account_upgrade_dialog_billing_contact_website": "Otázky týkající se fakturace naleznete na našich <Link>webových stránkách</Link>.", "account_upgrade_dialog_billing_contact_website": "Otázky týkající se fakturace naleznete na našich <Link>webových stránkách</Link>.",
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezervované téma", "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezervované téma",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} denní zpráva", "account_upgrade_dialog_tier_features_messages_one": "{{messages}} denní zpráva",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} denní e-mail" "account_upgrade_dialog_tier_features_emails_one": "{{emails}} denní e-mail",
"publish_dialog_call_label": "Telefonát",
"publish_dialog_call_reset": "Odstranit telefonát",
"publish_dialog_chip_call_label": "Telefonát",
"account_basics_phone_numbers_title": "Telefonní čísla",
"account_basics_phone_numbers_dialog_description": "Pro oznámení prostřednictvím tel. hovoru, musíte přidat a ověřit alespoň jedno telefonní číslo. Ověření lze provést pomocí SMS nebo telefonátu.",
"account_basics_phone_numbers_description": "K oznámení telefonátem",
"account_basics_phone_numbers_no_phone_numbers_yet": "Zatím žádná telefonní čísla",
"account_basics_phone_numbers_copied_to_clipboard": "Telefonní číslo zkopírováno do schránky",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Žádná ověřená telefonní čísla",
"publish_dialog_call_item": "Vytočit číslo {{number}}"
} }

View file

@ -355,5 +355,31 @@
"account_upgrade_dialog_billing_contact_email": "Para preguntas sobre facturación, por favor <Link>contáctenos</Link> directamente.", "account_upgrade_dialog_billing_contact_email": "Para preguntas sobre facturación, por favor <Link>contáctenos</Link> directamente.",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} mensaje diario", "account_upgrade_dialog_tier_features_messages_one": "{{messages}} mensaje diario",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} correo electrónico diario", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} correo electrónico diario",
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} tema reservado" "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} tema reservado",
"publish_dialog_call_label": "Llamada telefónica",
"publish_dialog_call_placeholder": "Número de teléfono al cual llamar con el mensaje, por ejemplo +12223334444, o \"sí\"",
"publish_dialog_chip_call_label": "Llamada telefónica",
"account_basics_phone_numbers_title": "Números de teléfono",
"account_basics_phone_numbers_description": "Para notificaciones por llamada teléfonica",
"account_basics_phone_numbers_no_phone_numbers_yet": "Aún no hay números de teléfono",
"account_basics_phone_numbers_dialog_number_label": "Número de teléfono",
"account_basics_phone_numbers_dialog_number_placeholder": "p. ej. +1222333444",
"account_basics_phone_numbers_dialog_verify_button_sms": "Envía SMS",
"account_basics_phone_numbers_dialog_verify_button_call": "Llámame",
"account_basics_phone_numbers_dialog_code_label": "Código de verificación",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "Llamar",
"account_usage_calls_title": "Llamadas telefónicas realizadas",
"account_usage_calls_none": "No se pueden hacer llamadas telefónicas con esta cuenta",
"account_upgrade_dialog_tier_features_calls_one": "{{llamadas}} llamadas telefónicas diarias",
"account_upgrade_dialog_tier_features_calls_other": "{{llamadas}} llamadas telefónicas diarias",
"account_upgrade_dialog_tier_features_no_calls": "No hay llamadas telefónicas",
"publish_dialog_call_reset": "Eliminar llamada telefónica",
"account_basics_phone_numbers_dialog_description": "Para utilizar la función de notificación de llamadas, tiene que añadir y verificar al menos un número de teléfono. La verificación puede realizarse mediante un SMS o una llamada telefónica.",
"account_basics_phone_numbers_copied_to_clipboard": "Número de teléfono copiado al portapapeles",
"account_basics_phone_numbers_dialog_check_verification_button": "Confirmar código",
"account_basics_phone_numbers_dialog_title": "Agregar número de teléfono",
"account_basics_phone_numbers_dialog_code_placeholder": "p.ej. 123456",
"publish_dialog_call_item": "Llamar al número de teléfono {{number}}",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "No hay números de teléfono verificados"
} }

View file

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

View file

@ -379,5 +379,7 @@
"account_basics_phone_numbers_dialog_channel_sms": "SMS", "account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} panggilan telepon harian", "account_upgrade_dialog_tier_features_calls_one": "{{calls}} panggilan telepon harian",
"account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon", "account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon",
"account_basics_phone_numbers_dialog_code_label": "Kode verifikasi" "account_basics_phone_numbers_dialog_code_label": "Kode verifikasi",
"publish_dialog_call_item": "Panggil nomor telepon {{number}}",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Tidak ada nomor telepon terverifikasi"
} }

View file

@ -355,5 +355,30 @@
"prefs_reservations_table_topic_header": "Onderwerp", "prefs_reservations_table_topic_header": "Onderwerp",
"prefs_reservations_table_access_header": "Toegang", "prefs_reservations_table_access_header": "Toegang",
"prefs_reservations_table_everyone_read_write": "Iedereen kan publiceren en abonneren", "prefs_reservations_table_everyone_read_write": "Iedereen kan publiceren en abonneren",
"prefs_reservations_table_not_subscribed": "Niet geabonneerd" "prefs_reservations_table_not_subscribed": "Niet geabonneerd",
"publish_dialog_call_label": "Telefoongesprek",
"publish_dialog_call_reset": "Telefoongesprek verwijderen",
"publish_dialog_chip_call_label": "Telefoongesprek",
"account_basics_phone_numbers_title": "Telefoonnummers",
"account_basics_phone_numbers_description": "Voor meldingen via telefoongesprekken",
"account_basics_phone_numbers_no_phone_numbers_yet": "Nog geen telefoonnummers",
"account_basics_phone_numbers_dialog_verify_button_call": "Bel me",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} dagelijkse telefoontjes",
"account_basics_phone_numbers_copied_to_clipboard": "Telefoonnummer gekopieerd naar klembord",
"publish_dialog_call_item": "Bel telefoonnummer {{nummer}}",
"account_basics_phone_numbers_dialog_check_verification_button": "Bevestig code",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Geen geverifieerde telefoonnummers",
"account_basics_phone_numbers_dialog_channel_call": "Telefoongesprek",
"account_basics_phone_numbers_dialog_number_label": "Telefoonnummer",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_code_placeholder": "bijv. 123456",
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} dagelijkse telefoontjes",
"account_upgrade_dialog_tier_features_no_calls": "Geen telefoontjes",
"account_basics_phone_numbers_dialog_description": "Als u de functie voor oproepmeldingen wilt gebruiken, moet u ten minste één telefoonnummer toevoegen en verifiëren. Verificatie kan worden gedaan via sms of een telefoontje.",
"account_basics_phone_numbers_dialog_title": "Telefoonnummer toevoegen",
"account_basics_phone_numbers_dialog_number_placeholder": "bijv. +1222333444",
"account_basics_phone_numbers_dialog_verify_button_sms": "Stuur SMS",
"account_basics_phone_numbers_dialog_code_label": "Verificatiecode",
"account_usage_calls_title": "Aantal telefoontjes",
"account_usage_calls_none": "Met dit account kan niet worden gebeld"
} }

View file

@ -214,5 +214,17 @@
"login_link_signup": "Registar", "login_link_signup": "Registar",
"action_bar_reservation_add": "Reservar tópico", "action_bar_reservation_add": "Reservar tópico",
"action_bar_sign_up": "Registar", "action_bar_sign_up": "Registar",
"nav_button_account": "Conta" "nav_button_account": "Conta",
"common_copy_to_clipboard": "Copiar",
"nav_upgrade_banner_label": "Atualizar para ntfy Pro",
"alert_not_supported_context_description": "Notificações são suportadas apenas sobre HTTPS. Essa é uma limitação da <mdnLink>API de Notificações</mdnLink>.",
"display_name_dialog_title": "Alterar nome mostrado",
"display_name_dialog_description": "Configura um nome alternativo ao tópico que é mostrado na lista de assinaturas. Isto ajuda a identificar tópicos com nomes complicados mais facilmente.",
"display_name_dialog_placeholder": "Nome exibido",
"reserve_dialog_checkbox_label": "Reservar tópico e configurar acesso",
"publish_dialog_call_label": "Chamada telefônica",
"publish_dialog_call_placeholder": "Número de telefone para ligar com a mensagem, ex: +12223334444, ou 'Sim'",
"publish_dialog_call_reset": "Remover chamada telefônica",
"publish_dialog_chip_call_label": "Chamada telefônica",
"subscribe_dialog_subscribe_button_generate_topic_name": "Gerar nome"
} }

View file

@ -355,5 +355,30 @@
"reservation_delete_dialog_action_keep_title": "Behåll cachade meddelanden och bilagor", "reservation_delete_dialog_action_keep_title": "Behåll cachade meddelanden och bilagor",
"reservation_delete_dialog_action_keep_description": "Meddelanden och bilagor som lagras på servern blir offentligt synliga för personer som känner till ämnesnamnet.", "reservation_delete_dialog_action_keep_description": "Meddelanden och bilagor som lagras på servern blir offentligt synliga för personer som känner till ämnesnamnet.",
"reservation_delete_dialog_action_delete_title": "Ta bort meddelanden och bilagor som sparats i cacheminnet", "reservation_delete_dialog_action_delete_title": "Ta bort meddelanden och bilagor som sparats i cacheminnet",
"reservation_delete_dialog_description": "Om du tar bort en reservation ger du upp äganderätten till ämnet och låter andra reservera det. Du kan behålla eller radera befintliga meddelanden och bilagor." "reservation_delete_dialog_description": "Om du tar bort en reservation ger du upp äganderätten till ämnet och låter andra reservera det. Du kan behålla eller radera befintliga meddelanden och bilagor.",
"publish_dialog_call_label": "Telefonsamtal",
"publish_dialog_call_reset": "Ta bort telefonsamtal",
"publish_dialog_chip_call_label": "Telefonsamtal",
"account_basics_phone_numbers_title": "Telefonnummer",
"account_basics_phone_numbers_description": "För notifieringar via telefonsamtal",
"account_basics_phone_numbers_no_phone_numbers_yet": "Inga telefonnummer ännu",
"account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer kopierat till urklipp",
"account_basics_phone_numbers_dialog_title": "Lägga till telefonnummer",
"account_basics_phone_numbers_dialog_number_label": "Telefonnummer",
"account_basics_phone_numbers_dialog_number_placeholder": "t.ex. +1222333444",
"account_basics_phone_numbers_dialog_verify_button_sms": "Skicka SMS",
"account_basics_phone_numbers_dialog_verify_button_call": "Ring mig",
"account_basics_phone_numbers_dialog_code_label": "Verifieringskod",
"account_basics_phone_numbers_dialog_channel_call": "Ring",
"account_usage_calls_title": "Telefonsamtal som gjorts",
"account_usage_calls_none": "Inga telefonsamtal kan göras med detta konto",
"publish_dialog_call_item": "Ring telefonnummer {{number}}",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Inga verifierade telefonnummer",
"account_basics_phone_numbers_dialog_description": "För att använda funktionen för samtalsavisering måste du lägga till och verifiera minst ett telefonnummer. Verifieringen kan göras via SMS eller ett telefonsamtal.",
"account_basics_phone_numbers_dialog_code_placeholder": "t.ex. 123456",
"account_basics_phone_numbers_dialog_check_verification_button": "Bekräfta kod",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} dagliga telefonsamtal",
"account_upgrade_dialog_tier_features_no_calls": "Inga telefonsamtal",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} dagliga telefonsamtal"
} }

View file

@ -295,5 +295,91 @@
"account_usage_messages_title": "Опубліковані повідомлення", "account_usage_messages_title": "Опубліковані повідомлення",
"account_usage_emails_title": "Надіслані електронні листи", "account_usage_emails_title": "Надіслані електронні листи",
"account_usage_reservations_title": "Зарезервовані теми", "account_usage_reservations_title": "Зарезервовані теми",
"account_usage_reservations_none": "Для цього облікового запису немає зарезервованих тем" "account_usage_reservations_none": "Для цього облікового запису немає зарезервованих тем",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} на файл",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} загальне сховище",
"account_upgrade_dialog_tier_current_label": "Поточний",
"account_upgrade_dialog_tier_selected_label": "Вибране",
"account_upgrade_dialog_cancel_warning": "Це <strong> скасує вашу підписку</strong> і знизить версію вашого облікового запису {{date}}. У цю дату резервування тем, а також повідомлення, кешовані на сервері <strong>, буде видалено</strong>.",
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} зарезервовані теми",
"account_upgrade_dialog_tier_features_no_reservations": "Немає зарезервованих тем",
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} повідомлень в день",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} електронний лист в день",
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} електронних листів в день",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} телефонний дзвінок в день",
"account_upgrade_dialog_tier_features_calls_other": "{{дзвінки}} телефонних дзвінків в день",
"account_upgrade_dialog_tier_features_no_calls": "Без телефонних дзвінків",
"account_upgrade_dialog_tier_price_per_month": "місяць",
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} на рік. Рахунок виставляється щомісяця.",
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} виставляється щорічно. Збережіть {{save}}.",
"account_upgrade_dialog_billing_contact_email": "Якщо у вас виникли запитання щодо оплати, <Link>зв’яжіться з нами</Link> безпосередньо.",
"account_upgrade_dialog_billing_contact_website": "Якщо у вас виникли запитання щодо оплати, відвідайте наш <Link>веб-сайт</Link>.",
"account_upgrade_dialog_button_cancel_subscription": "Скасувати підписку",
"account_upgrade_dialog_button_update_subscription": "Оновити підписку",
"account_tokens_title": "Токени доступу",
"account_tokens_table_expires_header": "Термін дії закінчується",
"account_tokens_description": "Використовуйте токени доступу при публікації та підписці через ntfy API, щоб не надсилати свої облікові дані. Ознайомтеся з <Link>документацією</Link>, щоб дізнатися більше.",
"account_tokens_table_token_header": "Токен",
"account_tokens_table_never_expires": "Ніколи не закінчується",
"account_tokens_table_label_header": "Мітка",
"account_tokens_table_current_session": "Поточний сеанс браузера",
"account_tokens_table_last_access_header": "Останній доступ",
"account_tokens_table_copied_to_clipboard": "Токен доступу скопійовано",
"account_tokens_table_cannot_delete_or_edit": "Неможливо редагувати або видалити токен поточного сеансу",
"account_tokens_table_create_token_button": "Створити токен доступу",
"account_tokens_table_last_origin_tooltip": "З IP-адреси {{ip}} натисніть для пошуку",
"account_tokens_dialog_title_create": "Створити токен доступу",
"account_tokens_dialog_button_cancel": "Скасувати",
"account_tokens_dialog_title_edit": "Редагувати токен доступу",
"account_tokens_dialog_title_delete": "Видалити токен доступу",
"account_tokens_dialog_label": "Мітка, наприклад, сповіщення Radarr",
"account_tokens_dialog_button_create": "Створити токен",
"account_tokens_dialog_button_update": "Оновити токен",
"account_tokens_dialog_expires_label": "Термін дії токену доступу закінчується через",
"account_tokens_dialog_expires_x_hours": "Термін дії токена закінчується через {{hours}} годин",
"account_tokens_dialog_expires_x_days": "Термін дії токена закінчується через {{days}} днів",
"account_tokens_delete_dialog_description": "Перш ніж видалити токен доступу, переконайтеся, що жодна програма або скрипт не використовує його. <strong>Ця дія не може бути скасована</strong>.",
"prefs_users_description_no_sync": "Користувачі та паролі не синхронізуються з вашим акаунтом.",
"prefs_users_table_cannot_delete_or_edit": "Неможливо видалити або відредагувати користувача, який увійшов у систему",
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} зарезервована тема",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} повідомлення в день",
"account_tokens_dialog_expires_unchanged": "Залишити термін придатності без змін",
"account_tokens_dialog_expires_never": "Термін дії токена ніколи не закінчується",
"account_tokens_delete_dialog_title": "Видалити токен доступу",
"account_tokens_delete_dialog_submit_button": "Видалити токен назавжди",
"account_upgrade_dialog_proration_info": "<strong>Пропорція</strong>: При переході з одного тарифного плану на інший різниця в ціні буде <strong>списана негайно</strong>. При переході на нижчий рівень залишок коштів буде використано для оплати майбутніх розрахункових періодів.",
"account_upgrade_dialog_reservations_warning_one": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, <strong>будь ласка, видаліть принаймні одне резервування</strong>. Ви можете видалити резервування в <Link>Налаштуваннях</Link>.",
"account_upgrade_dialog_reservations_warning_other": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, <strong>будь ласка, видаліть принаймні {{count}} резервувань</strong>. Ви можете видалити резервування в <Link>Налаштуваннях</Link>.",
"account_upgrade_dialog_button_cancel": "Скасувати",
"account_upgrade_dialog_button_redirect_signup": "Зареєструватися зараз",
"account_upgrade_dialog_button_pay_now": "Оплатити зараз і підписатися",
"prefs_reservations_add_button": "Додати зарезервовану тему",
"prefs_reservations_edit_button": "Редагувати доступ до теми",
"prefs_reservations_limit_reached": "Ви досягли ліміту зарезервованих тем.",
"prefs_reservations_table_click_to_subscribe": "Натисніть, щоб підписатися",
"prefs_reservations_table_topic_header": "Тема",
"prefs_reservations_description": "Тут ви можете зарезервувати назви тем для особистого користування. Резервування теми дає вам право власності на тему і дозволяє визначати права доступу до неї інших користувачів.",
"prefs_reservations_table": "Таблиця зарезервованих тем",
"prefs_reservations_table_access_header": "Доступ",
"prefs_reservations_table_everyone_deny_all": "Тільки я можу публікувати та підписуватись",
"prefs_reservations_table_everyone_read_only": "Я можу публікувати та підписуватись, кожен може підписатися",
"prefs_reservations_table_everyone_write_only": "Я можу публікувати і підписуватися, кожен може публікувати",
"prefs_reservations_table_everyone_read_write": "Кожен може публікувати та підписуватися",
"prefs_reservations_table_not_subscribed": "Не підписаний",
"prefs_reservations_dialog_title_add": "Зарезервувати тему",
"prefs_reservations_dialog_title_edit": "Редагувати зарезервовану тему",
"prefs_reservations_title": "Зарезервовані теми",
"prefs_reservations_delete_button": "Скинути доступ до теми",
"prefs_reservations_dialog_description": "Резервування теми дає вам право власності на цю тему і дозволяє визначати права доступу до неї інших користувачів.",
"prefs_reservations_dialog_topic_label": "Тема",
"prefs_reservations_dialog_access_label": "Доступ",
"reservation_delete_dialog_description": "Видалення резервування позбавляє вас права власності на тему і дозволяє іншим зарезервувати її. Ви можете зберегти або видалити існуючі повідомлення і вкладення.",
"reservation_delete_dialog_submit_button": "Видалити резервування",
"publish_dialog_call_item": "Телефонувати за номером {{номер}}",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Немає підтверджених номерів телефонів",
"prefs_reservations_dialog_title_delete": "Видалити резервування теми",
"reservation_delete_dialog_action_delete_title": "Видалення кешованих повідомлень і вкладень",
"reservation_delete_dialog_action_keep_title": "Збереження кешованих повідомлень і вкладень",
"reservation_delete_dialog_action_keep_description": "Повідомлення і вкладення, які кешуються на сервері, стають загальнодоступними для людей, які знають назву теми.",
"reservation_delete_dialog_action_delete_description": "Кешовані повідомлення та вкладення будуть видалені назавжди. Ця дія не може бути скасована."
} }

View file

@ -1,3 +1,4 @@
import i18n from "i18next";
import { import {
accountBillingPortalUrl, accountBillingPortalUrl,
accountBillingSubscriptionUrl, accountBillingSubscriptionUrl,
@ -17,7 +18,6 @@ import {
} from "./utils"; } from "./utils";
import session from "./Session"; import session from "./Session";
import subscriptionManager from "./SubscriptionManager"; import subscriptionManager from "./SubscriptionManager";
import i18n from "i18next";
import prefs from "./Prefs"; import prefs from "./Prefs";
import routes from "../components/routes"; import routes from "../components/routes";
import { fetchOrThrow, UnauthorizedError } from "./errors"; import { fetchOrThrow, UnauthorizedError } from "./errors";
@ -66,13 +66,13 @@ class AccountApi {
async create(username, password) { async create(username, password) {
const url = accountUrl(config.base_url); const url = accountUrl(config.base_url);
const body = JSON.stringify({ const body = JSON.stringify({
username: username, username,
password: password, password,
}); });
console.log(`[AccountApi] Creating user account ${url}`); console.log(`[AccountApi] Creating user account ${url}`);
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "POST", method: "POST",
body: body, body,
}); });
} }
@ -97,7 +97,7 @@ class AccountApi {
method: "DELETE", method: "DELETE",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
password: password, password,
}), }),
}); });
} }
@ -118,7 +118,7 @@ class AccountApi {
async createToken(label, expires) { async createToken(label, expires) {
const url = accountTokenUrl(config.base_url); const url = accountTokenUrl(config.base_url);
const body = { const body = {
label: label, label,
expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0, expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0,
}; };
console.log(`[AccountApi] Creating user access token ${url}`); console.log(`[AccountApi] Creating user access token ${url}`);
@ -132,8 +132,8 @@ class AccountApi {
async updateToken(token, label, expires) { async updateToken(token, label, expires) {
const url = accountTokenUrl(config.base_url); const url = accountTokenUrl(config.base_url);
const body = { const body = {
token: token, token,
label: label, label,
}; };
if (expires > 0) { if (expires > 0) {
body.expires = Math.floor(Date.now() / 1000) + expires; body.expires = Math.floor(Date.now() / 1000) + expires;
@ -171,7 +171,7 @@ class AccountApi {
await fetchOrThrow(url, { await fetchOrThrow(url, {
method: "PATCH", method: "PATCH",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: body, body,
}); });
} }
@ -179,13 +179,13 @@ class AccountApi {
const url = accountSubscriptionUrl(config.base_url); const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify({ const body = JSON.stringify({
base_url: baseUrl, base_url: baseUrl,
topic: topic, topic,
}); });
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, { const response = await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: body, body,
}); });
const subscription = await response.json(); // May throw SyntaxError const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription); console.log(`[AccountApi] Subscription`, subscription);
@ -196,14 +196,14 @@ class AccountApi {
const url = accountSubscriptionUrl(config.base_url); const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify({ const body = JSON.stringify({
base_url: baseUrl, base_url: baseUrl,
topic: topic, topic,
...payload, ...payload,
}); });
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, { const response = await fetchOrThrow(url, {
method: "PATCH", method: "PATCH",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: body, body,
}); });
const subscription = await response.json(); // May throw SyntaxError const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription); console.log(`[AccountApi] Subscription`, subscription);
@ -230,8 +230,8 @@ class AccountApi {
method: "POST", method: "POST",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
topic: topic, topic,
everyone: everyone, everyone,
}), }),
}); });
} }
@ -261,25 +261,25 @@ class AccountApi {
async createBillingSubscription(tier, interval) { async createBillingSubscription(tier, interval) {
console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`); console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`);
return await this.upsertBillingSubscription("POST", tier, interval); return this.upsertBillingSubscription("POST", tier, interval);
} }
async updateBillingSubscription(tier, interval) { async updateBillingSubscription(tier, interval) {
console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`); console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`);
return await this.upsertBillingSubscription("PUT", tier, interval); return this.upsertBillingSubscription("PUT", tier, interval);
} }
async upsertBillingSubscription(method, tier, interval) { async upsertBillingSubscription(method, tier, interval) {
const url = accountBillingSubscriptionUrl(config.base_url); const url = accountBillingSubscriptionUrl(config.base_url);
const response = await fetchOrThrow(url, { const response = await fetchOrThrow(url, {
method: method, method,
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
tier: tier, tier,
interval: interval, interval,
}), }),
}); });
return await response.json(); // May throw SyntaxError return response.json(); // May throw SyntaxError
} }
async deleteBillingSubscription() { async deleteBillingSubscription() {
@ -298,7 +298,7 @@ class AccountApi {
method: "POST", method: "POST",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
}); });
return await response.json(); // May throw SyntaxError return response.json(); // May throw SyntaxError
} }
async verifyPhoneNumber(phoneNumber, channel) { async verifyPhoneNumber(phoneNumber, channel) {
@ -309,7 +309,7 @@ class AccountApi {
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
number: phoneNumber, number: phoneNumber,
channel: channel, channel,
}), }),
}); });
} }
@ -322,12 +322,12 @@ class AccountApi {
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
number: phoneNumber, number: phoneNumber,
code: code, code,
}), }),
}); });
} }
async deletePhoneNumber(phoneNumber, code) { async deletePhoneNumber(phoneNumber) {
const url = accountPhoneUrl(config.base_url); const url = accountPhoneUrl(config.base_url);
console.log(`[AccountApi] Deleting phone number ${url}`); console.log(`[AccountApi] Deleting phone number ${url}`);
await fetchOrThrow(url, { await fetchOrThrow(url, {
@ -369,6 +369,7 @@ class AccountApi {
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} }
return undefined;
} }
} }

View file

@ -18,7 +18,7 @@ class Api {
const messages = []; const messages = [];
const headers = maybeWithAuth({}, user); const headers = maybeWithAuth({}, user);
console.log(`[Api] Polling ${url}`); console.log(`[Api] Polling ${url}`);
for await (let line of fetchLinesIterator(url, headers)) { for await (const line of fetchLinesIterator(url, headers)) {
const message = JSON.parse(line); const message = JSON.parse(line);
if (message.id) { if (message.id) {
console.log(`[Api, ${shortUrl}] Received message ${line}`); console.log(`[Api, ${shortUrl}] Received message ${line}`);
@ -33,8 +33,8 @@ class Api {
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`); console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
const headers = {}; const headers = {};
const body = { const body = {
topic: topic, topic,
message: message, message,
...options, ...options,
}; };
await fetchOrThrow(baseUrl, { await fetchOrThrow(baseUrl, {
@ -60,7 +60,7 @@ class Api {
publishXHR(url, body, headers, onProgress) { publishXHR(url, body, headers, onProgress) {
console.log(`[Api] Publishing message to ${url}`); console.log(`[Api] Publishing message to ${url}`);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
const send = new Promise(function (resolve, reject) { const send = new Promise((resolve, reject) => {
xhr.open("PUT", url); xhr.open("PUT", url);
if (body.type) { if (body.type) {
xhr.overrideMimeType(body.type); xhr.overrideMimeType(body.type);
@ -106,7 +106,8 @@ class Api {
}); });
if (response.status >= 200 && response.status <= 299) { if (response.status >= 200 && response.status <= 299) {
return true; return true;
} else if (response.status === 401 || response.status === 403) { }
if (response.status === 401 || response.status === 403) {
// See server/server.go // See server/server.go
return false; return false;
} }

View file

@ -1,7 +1,14 @@
/* eslint-disable max-classes-per-file */
import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils"; import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils";
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120]; const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
export class ConnectionState {
static Connected = "connected";
static Connecting = "connecting";
}
/** /**
* A connection contains a single WebSocket connection for one topic. It handles its connection * A connection contains a single WebSocket connection for one topic. It handles its connection
* status itself, including reconnect attempts and backoff. * status itself, including reconnect attempts and backoff.
@ -63,7 +70,7 @@ class Connection {
this.ws = null; this.ws = null;
} else { } else {
const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length - 1)]; const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length - 1)];
this.retryCount++; this.retryCount += 1;
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`); console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`);
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000); this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting); this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
@ -77,7 +84,7 @@ class Connection {
close() { close() {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`); console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`);
const socket = this.ws; const socket = this.ws;
const retryTimeout = this.retryTimeout; const { retryTimeout } = this;
if (socket !== null) { if (socket !== null) {
socket.close(); socket.close();
} }
@ -108,9 +115,4 @@ class Connection {
} }
} }
export class ConnectionState {
static Connected = "connected";
static Connecting = "connecting";
}
export default Connection; export default Connection;

View file

@ -1,6 +1,9 @@
import Connection from "./Connection"; import Connection from "./Connection";
import { hashCode } from "./utils"; import { hashCode } from "./utils";
const makeConnectionId = async (subscription, user) =>
user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`);
/** /**
* The connection manager keeps track of active connections (WebSocket connections, see Connection). * The connection manager keeps track of active connections (WebSocket connections, see Connection).
* *
@ -55,12 +58,10 @@ class ConnectionManager {
// Create and add new connections // Create and add new connections
subscriptionsWithUsersAndConnectionId.forEach((subscription) => { subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
const subscriptionId = subscription.id; const subscriptionId = subscription.id;
const connectionId = subscription.connectionId; const { connectionId } = subscription;
const added = !this.connections.get(connectionId); const added = !this.connections.get(connectionId);
if (added) { if (added) {
const baseUrl = subscription.baseUrl; const { baseUrl, topic, user } = subscription;
const topic = subscription.topic;
const user = subscription.user;
const since = subscription.last; const since = subscription.last;
const connection = new Connection( const connection = new Connection(
connectionId, connectionId,
@ -69,8 +70,8 @@ class ConnectionManager {
topic, topic,
user, user,
since, since,
(subscriptionId, notification) => this.notificationReceived(subscriptionId, notification), (subId, notification) => this.notificationReceived(subId, notification),
(subscriptionId, state) => this.stateChanged(subscriptionId, state) (subId, state) => this.stateChanged(subId, state)
); );
this.connections.set(connectionId, connection); this.connections.set(connectionId, connection);
console.log( console.log(
@ -112,9 +113,5 @@ class ConnectionManager {
} }
} }
const makeConnectionId = async (subscription, user) => {
return user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`);
};
const connectionManager = new ConnectionManager(); const connectionManager = new ConnectionManager();
export default connectionManager; export default connectionManager;

View file

@ -29,7 +29,7 @@ class Notifier {
icon: logo, icon: logo,
}); });
if (notification.click) { if (notification.click) {
n.onclick = (e) => openUrl(notification.click); n.onclick = () => openUrl(notification.click);
} else { } else {
n.onclick = () => onClickFallback(subscription); n.onclick = () => onClickFallback(subscription);
} }
@ -87,7 +87,7 @@ class Notifier {
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
*/ */
contextSupported() { contextSupported() {
return location.protocol === "https:" || location.hostname.match("^127.") || location.hostname === "localhost"; return window.location.protocol === "https:" || window.location.hostname.match("^127.") || window.location.hostname === "localhost";
} }
} }

View file

@ -21,13 +21,16 @@ class Poller {
async pollAll() { async pollAll() {
console.log(`[Poller] Polling all subscriptions`); console.log(`[Poller] Polling all subscriptions`);
const subscriptions = await subscriptionManager.all(); const subscriptions = await subscriptionManager.all();
for (const s of subscriptions) {
try { await Promise.all(
await this.poll(s); subscriptions.map(async (s) => {
} catch (e) { try {
console.log(`[Poller] Error polling ${s.id}`, e); await this.poll(s);
} } catch (e) {
} console.log(`[Poller] Error polling ${s.id}`, e);
}
})
);
} }
async poll(subscription) { async poll(subscription) {

View file

@ -5,16 +5,16 @@ class SubscriptionManager {
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */ /** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
async all() { async all() {
const subscriptions = await db.subscriptions.toArray(); const subscriptions = await db.subscriptions.toArray();
await Promise.all( return Promise.all(
subscriptions.map(async (s) => { subscriptions.map(async (s) => ({
s.new = await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(); ...s,
}) new: await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(),
}))
); );
return subscriptions;
} }
async get(subscriptionId) { async get(subscriptionId) {
return await db.subscriptions.get(subscriptionId); return db.subscriptions.get(subscriptionId);
} }
async add(baseUrl, topic, internal) { async add(baseUrl, topic, internal) {
@ -25,8 +25,8 @@ class SubscriptionManager {
} }
const subscription = { const subscription = {
id: topicUrl(baseUrl, topic), id: topicUrl(baseUrl, topic),
baseUrl: baseUrl, baseUrl,
topic: topic, topic,
mutedUntil: 0, mutedUntil: 0,
last: null, last: null,
internal: internal || false, internal: internal || false,
@ -39,36 +39,40 @@ class SubscriptionManager {
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions); console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
// Add remote subscriptions // Add remote subscriptions
let remoteIds = []; // = topicUrl(baseUrl, topic) const remoteIds = await Promise.all(
for (let i = 0; i < remoteSubscriptions.length; i++) { remoteSubscriptions.map(async (remote) => {
const remote = remoteSubscriptions[i]; const local = await this.add(remote.base_url, remote.topic, false);
const local = await this.add(remote.base_url, remote.topic, false); const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
await this.update(local.id, { await this.update(local.id, {
displayName: remote.display_name, // May be undefined displayName: remote.display_name, // May be undefined
reservation: reservation, // May be null! reservation, // May be null!
}); });
remoteIds.push(local.id);
} return local.id;
})
);
// Remove local subscriptions that do not exist remotely // Remove local subscriptions that do not exist remotely
const localSubscriptions = await db.subscriptions.toArray(); const localSubscriptions = await db.subscriptions.toArray();
for (let i = 0; i < localSubscriptions.length; i++) {
const local = localSubscriptions[i]; await Promise.all(
const remoteExists = remoteIds.includes(local.id); localSubscriptions.map(async (local) => {
if (!local.internal && !remoteExists) { const remoteExists = remoteIds.includes(local.id);
await this.remove(local.id); if (!local.internal && !remoteExists) {
} await this.remove(local.id);
} }
})
);
} }
async updateState(subscriptionId, state) { async updateState(subscriptionId, state) {
db.subscriptions.update(subscriptionId, { state: state }); db.subscriptions.update(subscriptionId, { state });
} }
async remove(subscriptionId) { async remove(subscriptionId) {
await db.subscriptions.delete(subscriptionId); await db.subscriptions.delete(subscriptionId);
await db.notifications.where({ subscriptionId: subscriptionId }).delete(); await db.notifications.where({ subscriptionId }).delete();
} }
async first() { async first() {
@ -101,8 +105,12 @@ class SubscriptionManager {
return false; return false;
} }
try { try {
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation await db.notifications.add({
await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab ...notification,
subscriptionId,
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
new: 1,
}); // FIXME consider put() for double tab
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {
last: notification.id, last: notification.id,
}); });
@ -140,7 +148,7 @@ class SubscriptionManager {
} }
async deleteNotifications(subscriptionId) { async deleteNotifications(subscriptionId) {
await db.notifications.where({ subscriptionId: subscriptionId }).delete(); await db.notifications.where({ subscriptionId }).delete();
} }
async markNotificationRead(notificationId) { async markNotificationRead(notificationId) {
@ -148,24 +156,24 @@ class SubscriptionManager {
} }
async markNotificationsRead(subscriptionId) { async markNotificationsRead(subscriptionId) {
await db.notifications.where({ subscriptionId: subscriptionId, new: 1 }).modify({ new: 0 }); await db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
} }
async setMutedUntil(subscriptionId, mutedUntil) { async setMutedUntil(subscriptionId, mutedUntil) {
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {
mutedUntil: mutedUntil, mutedUntil,
}); });
} }
async setDisplayName(subscriptionId, displayName) { async setDisplayName(subscriptionId, displayName) {
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {
displayName: displayName, displayName,
}); });
} }
async setReservation(subscriptionId, reservation) { async setReservation(subscriptionId, reservation) {
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {
reservation: reservation, reservation,
}); });
} }

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import { Base64 } from "js-base64";
import { rawEmojis } from "./emojis"; import { rawEmojis } from "./emojis";
import beep from "../sounds/beep.mp3"; import beep from "../sounds/beep.mp3";
import juntos from "../sounds/juntos.mp3"; import juntos from "../sounds/juntos.mp3";
@ -7,8 +8,11 @@ import dadum from "../sounds/dadum.mp3";
import pop from "../sounds/pop.mp3"; import pop from "../sounds/pop.mp3";
import popSwoosh from "../sounds/pop-swoosh.mp3"; import popSwoosh from "../sounds/pop-swoosh.mp3";
import config from "./config"; import config from "./config";
import { Base64 } from "js-base64";
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
export const expandSecureUrl = (url) => `https://${url}`;
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`; export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
export const topicUrlWs = (baseUrl, topic) => export const topicUrlWs = (baseUrl, topic) =>
`${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://"); `${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://");
@ -28,14 +32,10 @@ export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`; export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
export const expandSecureUrl = (url) => `https://${url}`;
export const validUrl = (url) => { export const validUrl = (url) => url.match(/^https?:\/\/.+/);
return url.match(/^https?:\/\/.+/);
}; export const disallowedTopic = (topic) => config.disallowed_topics.includes(topic);
export const validTopic = (topic) => { export const validTopic = (topic) => {
if (disallowedTopic(topic)) { if (disallowedTopic(topic)) {
@ -44,14 +44,11 @@ export const validTopic = (topic) => {
return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app! return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
}; };
export const disallowedTopic = (topic) => {
return config.disallowed_topics.includes(topic);
};
export const topicDisplayName = (subscription) => { export const topicDisplayName = (subscription) => {
if (subscription.displayName) { if (subscription.displayName) {
return subscription.displayName; return subscription.displayName;
} else if (subscription.baseUrl === config.base_url) { }
if (subscription.baseUrl === config.base_url) {
return subscription.topic; return subscription.topic;
} }
return topicShortUrl(subscription.baseUrl, subscription.topic); return topicShortUrl(subscription.baseUrl, subscription.topic);
@ -67,7 +64,15 @@ rawEmojis.forEach((emoji) => {
const toEmojis = (tags) => { const toEmojis = (tags) => {
if (!tags) return []; if (!tags) return [];
else return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]); return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]);
};
export const formatTitle = (m) => {
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.title}`;
}
return m.title;
}; };
export const formatTitleWithDefault = (m, fallback) => { export const formatTitleWithDefault = (m, fallback) => {
@ -77,41 +82,31 @@ export const formatTitleWithDefault = (m, fallback) => {
return fallback; return fallback;
}; };
export const formatTitle = (m) => {
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.title}`;
} else {
return m.title;
}
};
export const formatMessage = (m) => { export const formatMessage = (m) => {
if (m.title) { if (m.title) {
return m.message; return m.message;
} else {
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.message}`;
} else {
return m.message;
}
} }
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.message}`;
}
return m.message;
}; };
export const unmatchedTags = (tags) => { export const unmatchedTags = (tags) => {
if (!tags) return []; if (!tags) return [];
else return tags.filter((tag) => !(tag in emojis)); return tags.filter((tag) => !(tag in emojis));
}; };
export const maybeWithAuth = (headers, user) => { export const encodeBase64 = (s) => Base64.encode(s);
if (user && user.password) {
return withBasicAuth(headers, user.username, user.password); export const encodeBase64Url = (s) => Base64.encodeURI(s);
} else if (user && user.token) {
return withBearerAuth(headers, user.token); export const bearerAuth = (token) => `Bearer ${token}`;
}
return headers; export const basicAuth = (username, password) => `Basic ${encodeBase64(`${username}:${password}`)}`;
};
export const withBearerAuth = (headers, token) => ({ ...headers, Authorization: bearerAuth(token) });
export const maybeWithBearerAuth = (headers, token) => { export const maybeWithBearerAuth = (headers, token) => {
if (token) { if (token) {
@ -120,32 +115,18 @@ export const maybeWithBearerAuth = (headers, token) => {
return headers; return headers;
}; };
export const withBasicAuth = (headers, username, password) => { export const withBasicAuth = (headers, username, password) => ({ ...headers, Authorization: basicAuth(username, password) });
headers["Authorization"] = basicAuth(username, password);
export const maybeWithAuth = (headers, user) => {
if (user?.password) {
return withBasicAuth(headers, user.username, user.password);
}
if (user?.token) {
return withBearerAuth(headers, user.token);
}
return headers; return headers;
}; };
export const basicAuth = (username, password) => {
return `Basic ${encodeBase64(`${username}:${password}`)}`;
};
export const withBearerAuth = (headers, token) => {
headers["Authorization"] = bearerAuth(token);
return headers;
};
export const bearerAuth = (token) => {
return `Bearer ${token}`;
};
export const encodeBase64 = (s) => {
return Base64.encode(s);
};
export const encodeBase64Url = (s) => {
return Base64.encodeURI(s);
};
export const maybeAppendActionErrors = (message, notification) => { export const maybeAppendActionErrors = (message, notification) => {
const actionErrors = (notification.actions ?? []) const actionErrors = (notification.actions ?? [])
.map((action) => action.error) .map((action) => action.error)
@ -153,50 +134,47 @@ export const maybeAppendActionErrors = (message, notification) => {
.join("\n"); .join("\n");
if (actionErrors.length === 0) { if (actionErrors.length === 0) {
return message; return message;
} else {
return `${message}\n\n${actionErrors}`;
} }
return `${message}\n\n${actionErrors}`;
}; };
export const shuffle = (arr) => { export const shuffle = (arr) => {
let j, x; const returnArr = [...arr];
for (let index = arr.length - 1; index > 0; index--) {
j = Math.floor(Math.random() * (index + 1)); for (let index = returnArr.length - 1; index > 0; index -= 1) {
x = arr[index]; const j = Math.floor(Math.random() * (index + 1));
arr[index] = arr[j]; [returnArr[index], returnArr[j]] = [returnArr[j], returnArr[index]];
arr[j] = x;
} }
return arr;
return returnArr;
}; };
export const splitNoEmpty = (s, delimiter) => { export const splitNoEmpty = (s, delimiter) =>
return s s
.split(delimiter) .split(delimiter)
.map((x) => x.trim()) .map((x) => x.trim())
.filter((x) => x !== ""); .filter((x) => x !== "");
};
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */ /** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
export const hashCode = async (s) => { export const hashCode = async (s) => {
let hash = 0; let hash = 0;
for (let i = 0; i < s.length; i++) { for (let i = 0; i < s.length; i += 1) {
const char = s.charCodeAt(i); const char = s.charCodeAt(i);
// eslint-disable-next-line no-bitwise
hash = (hash << 5) - hash + char; hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer // eslint-disable-next-line no-bitwise
hash &= hash; // Convert to 32bit integer
} }
return hash; return hash;
}; };
export const formatShortDateTime = (timestamp) => { export const formatShortDateTime = (timestamp) =>
return new Intl.DateTimeFormat("default", { new Intl.DateTimeFormat("default", {
dateStyle: "short", dateStyle: "short",
timeStyle: "short", timeStyle: "short",
}).format(new Date(timestamp * 1000)); }).format(new Date(timestamp * 1000));
};
export const formatShortDate = (timestamp) => { export const formatShortDate = (timestamp) => new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000));
return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000));
};
export const formatBytes = (bytes, decimals = 2) => { export const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return "0 bytes"; if (bytes === 0) return "0 bytes";
@ -204,13 +182,14 @@ export const formatBytes = (bytes, decimals = 2) => {
const dm = decimals < 0 ? 0 : decimals; const dm = decimals < 0 ? 0 : decimals;
const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}; };
export const formatNumber = (n) => { export const formatNumber = (n) => {
if (n === 0) { if (n === 0) {
return n; return n;
} else if (n % 1000 === 0) { }
if (n % 1000 === 0) {
return `${n / 1000}k`; return `${n / 1000}k`;
} }
return n.toLocaleString(); return n.toLocaleString();
@ -264,10 +243,11 @@ export const playSound = async (id) => {
}; };
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
// eslint-disable-next-line func-style
export async function* fetchLinesIterator(fileURL, headers) { export async function* fetchLinesIterator(fileURL, headers) {
const utf8Decoder = new TextDecoder("utf-8"); const utf8Decoder = new TextDecoder("utf-8");
const response = await fetch(fileURL, { const response = await fetch(fileURL, {
headers: headers, headers,
}); });
const reader = response.body.getReader(); const reader = response.body.getReader();
let { value: chunk, done: readerDone } = await reader.read(); let { value: chunk, done: readerDone } = await reader.read();
@ -277,15 +257,18 @@ export async function* fetchLinesIterator(fileURL, headers) {
let startIndex = 0; let startIndex = 0;
for (;;) { for (;;) {
let result = re.exec(chunk); const result = re.exec(chunk);
if (!result) { if (!result) {
if (readerDone) { if (readerDone) {
break; break;
} }
let remainder = chunk.substr(startIndex); const remainder = chunk.substr(startIndex);
// eslint-disable-next-line no-await-in-loop
({ value: chunk, done: readerDone } = await reader.read()); ({ value: chunk, done: readerDone } = await reader.read());
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : ""); chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : "");
startIndex = re.lastIndex = 0; startIndex = 0;
re.lastIndex = 0;
// eslint-disable-next-line no-continue
continue; continue;
} }
yield chunk.substring(startIndex, result.index); yield chunk.substring(startIndex, result.index);
@ -299,7 +282,8 @@ export async function* fetchLinesIterator(fileURL, headers) {
export const randomAlphanumericString = (len) => { export const randomAlphanumericString = (len) => {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let id = ""; let id = "";
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i += 1) {
// eslint-disable-next-line no-bitwise
id += alphabet[(Math.random() * alphabet.length) | 0]; id += alphabet[(Math.random() * alphabet.length) | 0];
} }
return id; return id;

View file

@ -21,42 +21,42 @@ import {
TableHead, TableHead,
TableRow, TableRow,
useMediaQuery, useMediaQuery,
Tooltip,
Typography,
Container,
Card,
Button,
Dialog,
DialogTitle,
DialogContent,
TextField,
IconButton,
MenuItem,
DialogContentText,
} from "@mui/material"; } from "@mui/material";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
import Container from "@mui/material/Container";
import Card from "@mui/material/Card";
import Button from "@mui/material/Button";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import session from "../app/Session";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import theme from "./theme";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import TextField from "@mui/material/TextField";
import routes from "./routes";
import IconButton from "@mui/material/IconButton";
import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { Pref, PrefGroup } from "./Pref";
import db from "../app/db";
import i18n from "i18next"; import i18n from "i18next";
import humanizeDuration from "humanize-duration"; import humanizeDuration from "humanize-duration";
import UpgradeDialog from "./UpgradeDialog";
import CelebrationIcon from "@mui/icons-material/Celebration"; import CelebrationIcon from "@mui/icons-material/Celebration";
import CloseIcon from "@mui/icons-material/Close";
import { ContentCopy, Public } from "@mui/icons-material";
import AddIcon from "@mui/icons-material/Add";
import routes from "./routes";
import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
import { Pref, PrefGroup } from "./Pref";
import db from "../app/db";
import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App"; import { AccountContext } from "./App";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import { Paragraph } from "./styles"; import { Paragraph } from "./styles";
import CloseIcon from "@mui/icons-material/Close";
import { ContentCopy, Public } from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem";
import DialogContentText from "@mui/material/DialogContentText";
import { IncorrectPasswordError, UnauthorizedError } from "../app/errors"; import { IncorrectPasswordError, UnauthorizedError } from "../app/errors";
import { ProChip } from "./SubscriptionPopup"; import { ProChip } from "./SubscriptionPopup";
import AddIcon from "@mui/icons-material/Add"; import theme from "./theme";
import session from "../app/Session";
const Account = () => { const Account = () => {
if (!session.exists()) { if (!session.exists()) {
@ -439,23 +439,6 @@ const AddPhoneNumberDialog = (props) => {
const [verificationCodeSent, setVerificationCodeSent] = useState(false); const [verificationCodeSent, setVerificationCodeSent] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleDialogSubmit = async () => {
if (!verificationCodeSent) {
await verifyPhone();
} else {
await checkVerifyPhone();
}
};
const handleCancel = () => {
if (verificationCodeSent) {
setVerificationCodeSent(false);
setCode("");
} else {
props.onClose();
}
};
const verifyPhone = async () => { const verifyPhone = async () => {
try { try {
setSending(true); setSending(true);
@ -490,6 +473,23 @@ const AddPhoneNumberDialog = (props) => {
} }
}; };
const handleDialogSubmit = async () => {
if (!verificationCodeSent) {
await verifyPhone();
} else {
await checkVerifyPhone();
}
};
const handleCancel = () => {
if (verificationCodeSent) {
setVerificationCodeSent(false);
setCode("");
} else {
props.onClose();
}
};
return ( return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}> <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle> <DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
@ -561,9 +561,7 @@ const Stats = () => {
return <></>; return <></>;
} }
const normalize = (value, max) => { const normalize = (value, max) => Math.min((value / max) * 100, 100);
return Math.min((value / max) * 100, 100);
};
return ( return (
<Card sx={{ p: 3 }} aria-label={t("account_usage_title")}> <Card sx={{ p: 3 }} aria-label={t("account_usage_title")}>
@ -746,18 +744,16 @@ const Stats = () => {
); );
}; };
const InfoIcon = () => { const InfoIcon = () => (
return ( <InfoOutlinedIcon
<InfoOutlinedIcon sx={{
sx={{ verticalAlign: "middle",
verticalAlign: "middle", width: "18px",
width: "18px", marginLeft: "4px",
marginLeft: "4px", color: "gray",
color: "gray", }}
}} />
/> );
);
};
const Tokens = () => { const Tokens = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -775,10 +771,6 @@ const Tokens = () => {
setDialogOpen(false); setDialogOpen(false);
}; };
const handleDialogSubmit = async (user) => {
setDialogOpen(false);
//
};
return ( return (
<Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}> <Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
<CardContent sx={{ paddingBottom: 1 }}> <CardContent sx={{ paddingBottom: 1 }}>
@ -814,7 +806,8 @@ const TokensTable = (props) => {
const tokens = (props.tokens || []).sort((a, b) => { const tokens = (props.tokens || []).sort((a, b) => {
if (a.token === session.token()) { if (a.token === session.token()) {
return -1; return -1;
} else if (b.token === session.token()) { }
if (b.token === session.token()) {
return 1; return 1;
} }
return a.token.localeCompare(b.token); return a.token.localeCompare(b.token);
@ -1025,7 +1018,7 @@ const TokenDeleteDialog = (props) => {
<Trans i18nKey="account_tokens_delete_dialog_description" /> <Trans i18nKey="account_tokens_delete_dialog_description" />
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogFooter status> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button> <Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} color="error"> <Button onClick={handleSubmit} color="error">
{t("account_tokens_delete_dialog_submit_button")} {t("account_tokens_delete_dialog_submit_button")}

View file

@ -1,29 +1,21 @@
import AppBar from "@mui/material/AppBar"; import { AppBar, Toolbar, IconButton, Typography, Box, MenuItem, Button, Divider, ListItemIcon } from "@mui/material";
import Navigation from "./Navigation";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu"; import MenuIcon from "@mui/icons-material/Menu";
import Typography from "@mui/material/Typography";
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useState } from "react";
import Box from "@mui/material/Box";
import { topicDisplayName } from "../app/utils";
import db from "../app/db";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import MenuItem from "@mui/material/MenuItem";
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsIcon from "@mui/icons-material/Notifications"; import NotificationsIcon from "@mui/icons-material/Notifications";
import NotificationsOffIcon from "@mui/icons-material/NotificationsOff"; import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
import routes from "./routes";
import subscriptionManager from "../app/SubscriptionManager";
import logo from "../img/ntfy.svg";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import session from "../app/Session";
import AccountCircleIcon from "@mui/icons-material/AccountCircle"; import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import { Logout, Person, Settings } from "@mui/icons-material"; import { Logout, Person, Settings } from "@mui/icons-material";
import ListItemIcon from "@mui/material/ListItemIcon"; import session from "../app/Session";
import logo from "../img/ntfy.svg";
import subscriptionManager from "../app/SubscriptionManager";
import routes from "./routes";
import db from "../app/db";
import { topicDisplayName } from "../app/utils";
import Navigation from "./Navigation";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import PopupMenu from "./PopupMenu"; import PopupMenu from "./PopupMenu";
import { SubscriptionPopup } from "./SubscriptionPopup"; import { SubscriptionPopup } from "./SubscriptionPopup";
@ -86,7 +78,7 @@ const ActionBar = (props) => {
const SettingsIcons = (props) => { const SettingsIcons = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null); const [anchorEl, setAnchorEl] = useState(null);
const subscription = props.subscription; const { subscription } = props;
const handleToggleMute = async () => { const handleToggleMute = async () => {
const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future

View file

@ -1,19 +1,17 @@
import * as React from "react"; import * as React from "react";
import { createContext, Suspense, useContext, useEffect, useState } from "react"; import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react";
import Box from "@mui/material/Box"; import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress } from "@mui/material";
import { ThemeProvider } from "@mui/material/styles"; import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline"; import { useLiveQuery } from "dexie-react-hooks";
import Toolbar from "@mui/material/Toolbar"; import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
import { AllSubscriptions, SingleSubscription } from "./Notifications"; import { AllSubscriptions, SingleSubscription } from "./Notifications";
import theme from "./theme"; import theme from "./theme";
import Navigation from "./Navigation"; import Navigation from "./Navigation";
import ActionBar from "./ActionBar"; import ActionBar from "./ActionBar";
import notifier from "../app/Notifier"; import notifier from "../app/Notifier";
import Preferences from "./Preferences"; import Preferences from "./Preferences";
import { useLiveQuery } from "dexie-react-hooks";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
import { expandUrl } from "../app/utils"; import { expandUrl } from "../app/utils";
import ErrorBoundary from "./ErrorBoundary"; import ErrorBoundary from "./ErrorBoundary";
import routes from "./routes"; import routes from "./routes";
@ -21,7 +19,6 @@ import { useAccountListener, useBackgroundProcesses, useConnectionListeners } fr
import PublishDialog from "./PublishDialog"; import PublishDialog from "./PublishDialog";
import Messaging from "./Messaging"; import Messaging from "./Messaging";
import "./i18n"; // Translations! import "./i18n"; // Translations!
import { Backdrop, CircularProgress } from "@mui/material";
import Login from "./Login"; import Login from "./Login";
import Signup from "./Signup"; import Signup from "./Signup";
import Account from "./Account"; import Account from "./Account";
@ -30,11 +27,13 @@ export const AccountContext = createContext(null);
const App = () => { const App = () => {
const [account, setAccount] = useState(null); const [account, setAccount] = useState(null);
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
return ( return (
<Suspense fallback={<Loader />}> <Suspense fallback={<Loader />}>
<BrowserRouter> <BrowserRouter>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<AccountContext.Provider value={{ account, setAccount }}> <AccountContext.Provider value={accountMemo}>
<CssBaseline /> <CssBaseline />
<ErrorBoundary> <ErrorBoundary>
<Routes> <Routes>
@ -56,6 +55,10 @@ const App = () => {
); );
}; };
const updateTitle = (newNotificationsCount) => {
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
};
const Layout = () => { const Layout = () => {
const params = useParams(); const params = useParams();
const { account, setAccount } = useContext(AccountContext); const { account, setAccount } = useContext(AccountContext);
@ -66,12 +69,11 @@ const Layout = () => {
const subscriptions = useLiveQuery(() => subscriptionManager.all()); const subscriptions = useLiveQuery(() => subscriptionManager.all());
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal); const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
const [selected] = (subscriptionsWithoutInternal || []).filter((s) => { const [selected] = (subscriptionsWithoutInternal || []).filter(
return ( (s) =>
(params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) || (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) ||
(config.base_url === s.baseUrl && params.topic === s.topic) (config.base_url === s.baseUrl && params.topic === s.topic)
); );
});
useConnectionListeners(account, subscriptions, users); useConnectionListeners(account, subscriptions, users);
useAccountListener(setAccount); useAccountListener(setAccount);
@ -95,7 +97,7 @@ const Layout = () => {
<Outlet <Outlet
context={{ context={{
subscriptions: subscriptionsWithoutInternal, subscriptions: subscriptionsWithoutInternal,
selected: selected, selected,
}} }}
/> />
</Main> </Main>
@ -104,41 +106,35 @@ const Layout = () => {
); );
}; };
const Main = (props) => { const Main = (props) => (
return ( <Box
<Box id="main"
id="main" component="main"
component="main" sx={{
sx={{ display: "flex",
display: "flex", flexGrow: 1,
flexGrow: 1, flexDirection: "column",
flexDirection: "column", padding: 3,
padding: 3, width: { sm: `calc(100% - ${Navigation.width}px)` },
width: { sm: `calc(100% - ${Navigation.width}px)` }, height: "100vh",
height: "100vh", overflow: "auto",
overflow: "auto", backgroundColor: ({ palette }) => (palette.mode === "light" ? palette.grey[100] : palette.grey[900]),
backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]), }}
}} >
> {props.children}
{props.children} </Box>
</Box> );
);
};
const Loader = () => ( const Loader = () => (
<Backdrop <Backdrop
open={true} open
sx={{ sx={{
zIndex: 100000, zIndex: 100000,
backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]), backgroundColor: ({ palette }) => (palette.mode === "light" ? palette.grey[100] : palette.grey[900]),
}} }}
> >
<CircularProgress color="success" disableShrink /> <CircularProgress color="success" disableShrink />
</Backdrop> </Backdrop>
); );
const updateTitle = (newNotificationsCount) => {
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
};
export default App; export default App;

View file

@ -1,16 +1,17 @@
import * as React from "react"; import * as React from "react";
import Box from "@mui/material/Box"; import { Box } from "@mui/material";
import { useTranslation } from "react-i18next";
import fileDocument from "../img/file-document.svg"; import fileDocument from "../img/file-document.svg";
import fileImage from "../img/file-image.svg"; import fileImage from "../img/file-image.svg";
import fileVideo from "../img/file-video.svg"; import fileVideo from "../img/file-video.svg";
import fileAudio from "../img/file-audio.svg"; import fileAudio from "../img/file-audio.svg";
import fileApp from "../img/file-app.svg"; import fileApp from "../img/file-app.svg";
import { useTranslation } from "react-i18next";
const AttachmentIcon = (props) => { const AttachmentIcon = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const type = props.type; const { type } = props;
let imageFile, imageLabel; let imageFile;
let imageLabel;
if (!type) { if (!type) {
imageFile = fileDocument; imageFile = fileDocument;
imageLabel = t("notifications_attachment_file_image"); imageLabel = t("notifications_attachment_file_image");

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,10 @@
import * as React from "react"; import * as React from "react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import Typography from "@mui/material/Typography"; import { Typography, Box, TextField, ClickAwayListener, Fade, InputAdornment, styled, IconButton, Popper } from "@mui/material";
import { rawEmojis } from "../app/emojis";
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField";
import { ClickAwayListener, Fade, InputAdornment, styled } from "@mui/material";
import IconButton from "@mui/material/IconButton";
import { Close } from "@mui/icons-material"; import { Close } from "@mui/icons-material";
import Popper from "@mui/material/Popper";
import { splitNoEmpty } from "../app/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { splitNoEmpty } from "../app/utils";
import { rawEmojis } from "../app/emojis";
// Create emoji list by category and create a search base (string with all search words) // Create emoji list by category and create a search base (string with all search words)
// //
@ -28,7 +23,7 @@ rawEmojis.forEach((emoji) => {
const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome; const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
if (supportedEmoji) { if (supportedEmoji) {
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`; const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`;
const emojiWithSearchBase = { ...emoji, searchBase: searchBase }; const emojiWithSearchBase = { ...emoji, searchBase };
emojisByCategory[emoji.category].push(emojiWithSearchBase); emojisByCategory[emoji.category].push(emojiWithSearchBase);
} }
} catch (e) { } catch (e) {
@ -132,8 +127,10 @@ const Category = (props) => {
); );
}; };
const emojiMatches = (emoji, words) => words.length === 0 || words.some((word) => emoji.searchBase.includes(word));
const Emoji = (props) => { const Emoji = (props) => {
const emoji = props.emoji; const { emoji } = props;
const matches = emojiMatches(emoji, props.search); const matches = emojiMatches(emoji, props.search);
const title = `${emoji.description} (${emoji.aliases[0]})`; const title = `${emoji.description} (${emoji.aliases[0]})`;
return ( return (
@ -158,16 +155,4 @@ const EmojiDiv = styled("div")({
}, },
}); });
const emojiMatches = (emoji, words) => {
if (words.length === 0) {
return true;
}
for (const word of words) {
if (emoji.searchBase.indexOf(word) === -1) {
return false;
}
}
return true;
};
export default EmojiPicker; export default EmojiPicker;

View file

@ -1,7 +1,6 @@
import * as React from "react"; import * as React from "react";
import StackTrace from "stacktrace-js"; import StackTrace from "stacktrace-js";
import { CircularProgress, Link } from "@mui/material"; import { CircularProgress, Link, Button } 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 { class ErrorBoundaryImpl extends React.Component {
@ -46,9 +45,8 @@ class ErrorBoundaryImpl extends React.Component {
// Fetch additional info and a better stack trace // Fetch additional info and a better stack trace
StackTrace.fromError(error).then((stack) => { StackTrace.fromError(error).then((stack) => {
console.error("[ErrorBoundary] Stacktrace fetched", stack); console.error("[ErrorBoundary] Stacktrace fetched", stack);
const niceStack = const stackString = stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
`${error.toString()}\n` + const niceStack = `${error.toString()}\n${stackString}`;
stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
this.setState({ niceStack }); this.setState({ niceStack });
}); });
} }
@ -69,17 +67,6 @@ class ErrorBoundaryImpl extends React.Component {
navigator.clipboard.writeText(stack); navigator.clipboard.writeText(stack);
} }
render() {
if (this.state.error) {
if (this.state.unsupportedIndexedDB) {
return this.renderUnsupportedIndexedDB();
} else {
return this.renderError();
}
}
return this.props.children;
}
renderUnsupportedIndexedDB() { renderUnsupportedIndexedDB() {
const { t } = this.props; const { t } = this.props;
return ( return (
@ -131,6 +118,16 @@ class ErrorBoundaryImpl extends React.Component {
</div> </div>
); );
} }
render() {
if (this.state.error) {
if (this.state.unsupportedIndexedDB) {
return this.renderUnsupportedIndexedDB();
}
return this.renderError();
}
return this.props.children;
}
} }
const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t

View file

@ -1,19 +1,14 @@
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useState } from "react";
import Typography from "@mui/material/Typography"; import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material";
import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import routes from "./routes";
import session from "../app/Session";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import AvatarBox from "./AvatarBox";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import accountApi from "../app/AccountApi";
import IconButton from "@mui/material/IconButton";
import { InputAdornment } from "@mui/material";
import { Visibility, VisibilityOff } from "@mui/icons-material"; import { Visibility, VisibilityOff } from "@mui/icons-material";
import accountApi from "../app/AccountApi";
import AvatarBox from "./AvatarBox";
import session from "../app/Session";
import routes from "./routes";
import { UnauthorizedError } from "../app/errors"; import { UnauthorizedError } from "../app/errors";
const Login = () => { const Login = () => {

View file

@ -1,21 +1,18 @@
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useState } from "react";
import Navigation from "./Navigation"; import { Paper, IconButton, TextField, Portal, Snackbar } from "@mui/material";
import Paper from "@mui/material/Paper";
import IconButton from "@mui/material/IconButton";
import TextField from "@mui/material/TextField";
import SendIcon from "@mui/icons-material/Send"; import SendIcon from "@mui/icons-material/Send";
import api from "../app/Api";
import PublishDialog from "./PublishDialog";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { Portal, Snackbar } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import PublishDialog from "./PublishDialog";
import api from "../app/Api";
import Navigation from "./Navigation";
const Messaging = (props) => { const Messaging = (props) => {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [dialogKey, setDialogKey] = useState(0); const [dialogKey, setDialogKey] = useState(0);
const dialogOpenMode = props.dialogOpenMode; const { dialogOpenMode } = props;
const subscription = props.selected; const subscription = props.selected;
const handleOpenDialogClick = () => { const handleOpenDialogClick = () => {
@ -39,7 +36,7 @@ const Messaging = (props) => {
topic={subscription?.topic ?? ""} topic={subscription?.topic ?? ""}
message={message} message={message}
onClose={handleDialogClose} onClose={handleDialogClose}
onDragEnter={() => props.onDialogOpenModeChange((prev) => (prev ? prev : PublishDialog.OPEN_MODE_DRAG))} // Only update if not already open onDragEnter={() => props.onDialogOpenModeChange((prev) => prev || PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open
onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)} onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}
/> />
</> </>
@ -48,7 +45,7 @@ const Messaging = (props) => {
const MessageBar = (props) => { const MessageBar = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const subscription = props.subscription; const { subscription } = props;
const [snackOpen, setSnackOpen] = useState(false); const [snackOpen, setSnackOpen] = useState(false);
const handleSendClick = async () => { const handleSendClick = async () => {
try { try {

View file

@ -1,38 +1,47 @@
import Drawer from "@mui/material/Drawer"; import {
Drawer,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Divider,
List,
Alert,
AlertTitle,
Badge,
CircularProgress,
Link,
ListSubheader,
Portal,
Tooltip,
Button,
Typography,
Box,
IconButton,
} from "@mui/material";
import * as React from "react"; import * as React from "react";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
import Person from "@mui/icons-material/Person"; import Person from "@mui/icons-material/Person";
import ListItemText from "@mui/material/ListItemText";
import Toolbar from "@mui/material/Toolbar";
import Divider from "@mui/material/Divider";
import List from "@mui/material/List";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import { useLocation, useNavigate } from "react-router-dom";
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
import ArticleIcon from "@mui/icons-material/Article";
import { Trans, useTranslation } from "react-i18next";
import CelebrationIcon from "@mui/icons-material/Celebration";
import SubscribeDialog from "./SubscribeDialog"; import SubscribeDialog from "./SubscribeDialog";
import { Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Portal, Tooltip } from "@mui/material";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import { openUrl, topicDisplayName, topicUrl } from "../app/utils"; import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
import routes from "./routes"; import routes from "./routes";
import { ConnectionState } from "../app/Connection"; import { ConnectionState } from "../app/Connection";
import { useLocation, useNavigate } from "react-router-dom";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
import Box from "@mui/material/Box";
import notifier from "../app/Notifier"; import notifier from "../app/Notifier";
import config from "../app/config"; import config from "../app/config";
import ArticleIcon from "@mui/icons-material/Article";
import { Trans, useTranslation } from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import accountApi, { Permission, Role } from "../app/AccountApi"; import accountApi, { Permission, Role } from "../app/AccountApi";
import CelebrationIcon from "@mui/icons-material/Celebration";
import UpgradeDialog from "./UpgradeDialog"; import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App"; import { AccountContext } from "./App";
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
import IconButton from "@mui/material/IconButton";
import { SubscriptionPopup } from "./SubscriptionPopup"; import { SubscriptionPopup } from "./SubscriptionPopup";
const navWidth = 280; const navWidth = 280;
@ -85,6 +94,10 @@ const NavList = (props) => {
setSubscribeDialogKey((prev) => prev + 1); setSubscribeDialogKey((prev) => prev + 1);
}; };
const handleRequestNotificationPermission = () => {
notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted));
};
const handleSubscribeSubmit = (subscription) => { const handleSubscribeSubmit = (subscription) => {
console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
handleSubscribeReset(); handleSubscribeReset();
@ -92,10 +105,6 @@ const NavList = (props) => {
handleRequestNotificationPermission(); handleRequestNotificationPermission();
}; };
const handleRequestNotificationPermission = () => {
notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted));
};
const handleAccountClick = () => { const handleAccountClick = () => {
accountApi.sync(); // Dangle! accountApi.sync(); // Dangle!
navigate(routes.account); navigate(routes.account);
@ -237,9 +246,7 @@ const UpgradeBanner = () => {
const SubscriptionList = (props) => { const SubscriptionList = (props) => {
const sortedSubscriptions = props.subscriptions const sortedSubscriptions = props.subscriptions
.filter((s) => !s.internal) .filter((s) => !s.internal)
.sort((a, b) => { .sort((a, b) => (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1));
return topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1;
});
return ( return (
<> <>
{sortedSubscriptions.map((subscription) => ( {sortedSubscriptions.map((subscription) => (
@ -258,7 +265,7 @@ const SubscriptionItem = (props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [menuAnchorEl, setMenuAnchorEl] = useState(null); const [menuAnchorEl, setMenuAnchorEl] = useState(null);
const subscription = props.subscription; const { subscription } = props;
const iconBadge = subscription.new <= 99 ? subscription.new : "99+"; const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
const displayName = topicDisplayName(subscription); const displayName = topicDisplayName(subscription);
const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName; const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName;

View file

@ -1,9 +1,29 @@
import Container from "@mui/material/Container"; import {
import { ButtonBase, CardActions, CardContent, CircularProgress, Fade, Link, Modal, Snackbar, Stack, Tooltip } from "@mui/material"; Container,
import Card from "@mui/material/Card"; ButtonBase,
import Typography from "@mui/material/Typography"; CardActions,
CardContent,
CircularProgress,
Fade,
Link,
Modal,
Snackbar,
Stack,
Tooltip,
Card,
Typography,
IconButton,
Box,
Button,
} from "@mui/material";
import * as React from "react"; import * as React from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import { useLiveQuery } from "dexie-react-hooks";
import InfiniteScroll from "react-infinite-scroll-component";
import { Trans, useTranslation } from "react-i18next";
import { useOutletContext } from "react-router-dom";
import { import {
formatBytes, formatBytes,
formatMessage, formatMessage,
@ -15,25 +35,23 @@ import {
topicShortUrl, topicShortUrl,
unmatchedTags, unmatchedTags,
} from "../app/utils"; } 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 { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
import { useLiveQuery } from "dexie-react-hooks";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import InfiniteScroll from "react-infinite-scroll-component";
import priority1 from "../img/priority-1.svg"; import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg"; import priority2 from "../img/priority-2.svg";
import priority4 from "../img/priority-4.svg"; import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg"; import priority5 from "../img/priority-5.svg";
import logoOutline from "../img/ntfy-outline.svg"; import logoOutline from "../img/ntfy-outline.svg";
import AttachmentIcon from "./AttachmentIcon"; import AttachmentIcon from "./AttachmentIcon";
import { Trans, useTranslation } from "react-i18next";
import { useOutletContext } from "react-router-dom";
import { useAutoSubscribe } from "./hooks"; import { useAutoSubscribe } from "./hooks";
const priorityFiles = {
1: priority1,
2: priority2,
4: priority4,
5: priority5,
};
export const AllSubscriptions = () => { export const AllSubscriptions = () => {
const { subscriptions } = useOutletContext(); const { subscriptions } = useOutletContext();
if (!subscriptions) { if (!subscriptions) {
@ -52,46 +70,50 @@ export const SingleSubscription = () => {
}; };
const AllSubscriptionsList = (props) => { const AllSubscriptionsList = (props) => {
const subscriptions = props.subscriptions; const { subscriptions } = props;
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []); const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
if (notifications === null || notifications === undefined) { if (notifications === null || notifications === undefined) {
return <Loading />; return <Loading />;
} else if (subscriptions.length === 0) { }
if (subscriptions.length === 0) {
return <NoSubscriptions />; return <NoSubscriptions />;
} else if (notifications.length === 0) { }
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 SingleSubscriptionList = (props) => { const SingleSubscriptionList = (props) => {
const subscription = props.subscription; const { subscription } = props;
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]); const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
if (notifications === null || notifications === undefined) { if (notifications === null || notifications === undefined) {
return <Loading />; return <Loading />;
} else if (notifications.length === 0) { }
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 />;
}; };
const NotificationList = (props) => { const NotificationList = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const pageSize = 20; const pageSize = 20;
const notifications = props.notifications; const { notifications } = props;
const [snackOpen, setSnackOpen] = useState(false); const [snackOpen, setSnackOpen] = useState(false);
const [maxCount, setMaxCount] = useState(pageSize); const [maxCount, setMaxCount] = useState(pageSize);
const count = Math.min(notifications.length, maxCount); const count = Math.min(notifications.length, maxCount);
useEffect(() => { useEffect(
return () => { () => () => {
setMaxCount(pageSize); setMaxCount(pageSize);
const main = document.getElementById("main"); const main = document.getElementById("main");
if (main) { if (main) {
main.scrollTo(0, 0); main.scrollTo(0, 0);
} }
}; },
}, [props.id]); [props.id]
);
return ( return (
<InfiniteScroll <InfiniteScroll
@ -127,10 +149,29 @@ const NotificationList = (props) => {
); );
}; };
/**
* Replace links with <Link/> components; this is a combination of the genius function
* in [1] and the regex in [2].
*
* [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760
* [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9
*/
const autolink = (s) => {
const parts = s.split(/(\bhttps?:\/\/[-A-Z0-9+\u0026\u2019@#/%?=()~_|!:,.;]*[-A-Z0-9+\u0026@#/%=~()_|]\b)/gi);
for (let i = 1; i < parts.length; i += 2) {
parts[i] = (
<Link key={i} href={parts[i]} underline="hover" target="_blank" rel="noreferrer,noopener">
{shortUrl(parts[i])}
</Link>
);
}
return <>{parts}</>;
};
const NotificationItem = (props) => { const NotificationItem = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const notification = props.notification; const { notification } = props;
const attachment = notification.attachment; const { attachment } = notification;
const date = formatShortDateTime(notification.time); const date = formatShortDateTime(notification.time);
const otherTags = unmatchedTags(notification.tags); const otherTags = unmatchedTags(notification.tags);
const tags = otherTags.length > 0 ? otherTags.join(", ") : null; const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
@ -244,35 +285,9 @@ const NotificationItem = (props) => {
); );
}; };
/**
* Replace links with <Link/> components; this is a combination of the genius function
* in [1] and the regex in [2].
*
* [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760
* [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9
*/
const autolink = (s) => {
const parts = s.split(/(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi);
for (let i = 1; i < parts.length; i += 2) {
parts[i] = (
<Link key={i} href={parts[i]} underline="hover" target="_blank" rel="noreferrer,noopener">
{shortUrl(parts[i])}
</Link>
);
}
return <>{parts}</>;
};
const priorityFiles = {
1: priority1,
2: priority2,
4: priority4,
5: priority5,
};
const Attachment = (props) => { const Attachment = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const attachment = props.attachment; const { attachment } = props;
const expired = attachment.expires && attachment.expires < Date.now() / 1000; const expired = attachment.expires && attachment.expires < Date.now() / 1000;
const expires = attachment.expires && attachment.expires > Date.now() / 1000; const expires = attachment.expires && attachment.expires > Date.now() / 1000;
const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/"); const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
@ -402,66 +417,29 @@ const Image = (props) => {
); );
}; };
const UserActions = (props) => { const UserActions = (props) => (
return ( <>
<> {props.notification.actions.map((action) => (
{props.notification.actions.map((action) => ( <UserAction key={action.id} notification={props.notification} action={action} />
<UserAction key={action.id} notification={props.notification} action={action} /> ))}
))} </>
</> );
);
const ACTION_PROGRESS_ONGOING = 1;
const ACTION_PROGRESS_SUCCESS = 2;
const ACTION_PROGRESS_FAILED = 3;
const ACTION_LABEL_SUFFIX = {
[ACTION_PROGRESS_ONGOING]: " …",
[ACTION_PROGRESS_SUCCESS]: " ✔",
[ACTION_PROGRESS_FAILED]: " ❌",
}; };
const UserAction = (props) => { const updateActionStatus = (notification, action, progress, error) => {
const { t } = useTranslation(); subscriptionManager.updateNotification({
const notification = props.notification; ...notification,
const action = props.action; actions: notification.actions.map((a) => (a.id === action.id ? { ...a, progress, error } : a)),
if (action.action === "broadcast") { });
return (
<Tooltip title={t("notifications_actions_not_supported")}>
<span>
<Button disabled aria-label={t("notifications_actions_not_supported")}>
{action.label}
</Button>
</span>
</Tooltip>
);
} else if (action.action === "view") {
return (
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
<Button
onClick={() => openUrl(action.url)}
aria-label={t("notifications_actions_open_url_title", {
url: action.url,
})}
>
{action.label}
</Button>
</Tooltip>
);
} else if (action.action === "http") {
const method = action.method ?? "POST";
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
return (
<Tooltip
title={t("notifications_actions_http_request_title", {
method: method,
url: action.url,
})}
>
<Button
onClick={() => performHttpAction(notification, action)}
aria-label={t("notifications_actions_http_request_title", {
method: method,
url: action.url,
})}
>
{label}
</Button>
</Tooltip>
);
}
return null; // Others
}; };
const performHttpAction = async (notification, action) => { const performHttpAction = async (notification, action) => {
@ -488,29 +466,63 @@ const performHttpAction = async (notification, action) => {
} }
}; };
const updateActionStatus = (notification, action, progress, error) => { const UserAction = (props) => {
notification.actions = notification.actions.map((a) => { const { t } = useTranslation();
if (a.id !== action.id) { const { notification } = props;
return a; const { action } = props;
} if (action.action === "broadcast") {
return { ...a, progress: progress, error: error }; return (
}); <Tooltip title={t("notifications_actions_not_supported")}>
subscriptionManager.updateNotification(notification); <span>
}; <Button disabled aria-label={t("notifications_actions_not_supported")}>
{action.label}
const ACTION_PROGRESS_ONGOING = 1; </Button>
const ACTION_PROGRESS_SUCCESS = 2; </span>
const ACTION_PROGRESS_FAILED = 3; </Tooltip>
);
const ACTION_LABEL_SUFFIX = { }
[ACTION_PROGRESS_ONGOING]: " …", if (action.action === "view") {
[ACTION_PROGRESS_SUCCESS]: " ✔", return (
[ACTION_PROGRESS_FAILED]: " ❌", <Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
<Button
onClick={() => openUrl(action.url)}
aria-label={t("notifications_actions_open_url_title", {
url: action.url,
})}
>
{action.label}
</Button>
</Tooltip>
);
}
if (action.action === "http") {
const method = action.method ?? "POST";
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
return (
<Tooltip
title={t("notifications_actions_http_request_title", {
method,
url: action.url,
})}
>
<Button
onClick={() => performHttpAction(notification, action)}
aria-label={t("notifications_actions_http_request_title", {
method,
url: action.url,
})}
>
{label}
</Button>
</Tooltip>
);
}
return null; // Others
}; };
const NoNotifications = (props) => { const NoNotifications = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic); const topicShortUrlResolved = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
return ( return (
<VerticallyCenteredContainer maxWidth="xs"> <VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
@ -521,7 +533,10 @@ const NoNotifications = (props) => {
<Paragraph>{t("notifications_none_for_topic_description")}</Paragraph> <Paragraph>{t("notifications_none_for_topic_description")}</Paragraph>
<Paragraph> <Paragraph>
{t("notifications_example")}:<br /> {t("notifications_example")}:<br />
<tt>$ curl -d "Hi" {shortUrl}</tt> <tt>
{'$ curl -d "Hi" '}
{topicShortUrlResolved}
</tt>
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
<ForMoreDetails /> <ForMoreDetails />
@ -533,7 +548,7 @@ const NoNotifications = (props) => {
const NoNotificationsWithoutSubscription = (props) => { const NoNotificationsWithoutSubscription = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const subscription = props.subscriptions[0]; const subscription = props.subscriptions[0];
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); const topicShortUrlResolved = topicShortUrl(subscription.baseUrl, subscription.topic);
return ( return (
<VerticallyCenteredContainer maxWidth="xs"> <VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
@ -544,7 +559,10 @@ const NoNotificationsWithoutSubscription = (props) => {
<Paragraph>{t("notifications_none_for_any_description")}</Paragraph> <Paragraph>{t("notifications_none_for_any_description")}</Paragraph>
<Paragraph> <Paragraph>
{t("notifications_example")}:<br /> {t("notifications_example")}:<br />
<tt>$ curl -d "Hi" {shortUrl}</tt> <tt>
{'$ curl -d "Hi" '}
{topicShortUrlResolved}
</tt>
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
<ForMoreDetails /> <ForMoreDetails />
@ -574,17 +592,15 @@ const NoSubscriptions = () => {
); );
}; };
const ForMoreDetails = () => { const ForMoreDetails = () => (
return ( <Trans
<Trans i18nKey="notifications_more_details"
i18nKey="notifications_more_details" components={{
components={{ websiteLink: <Link href="https://ntfy.sh" 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" />,
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />, }}
}} />
/> );
);
};
const Loading = () => { const Loading = () => {
const { t } = useTranslation(); const { t } = useTranslation();

View file

@ -37,8 +37,8 @@ const PopupMenu = (props) => {
}, },
}, },
}} }}
transformOrigin={{ horizontal: horizontal, vertical: "top" }} transformOrigin={{ horizontal, vertical: "top" }}
anchorOrigin={{ horizontal: horizontal, vertical: "bottom" }} anchorOrigin={{ horizontal, vertical: "bottom" }}
> >
{props.children} {props.children}
</Menu> </Menu>

View file

@ -1,8 +1,6 @@
import * as React from "react"; import * as React from "react";
export const PrefGroup = (props) => { export const PrefGroup = (props) => <div role="table">{props.children}</div>;
return <div role="table">{props.children}</div>;
};
export const Pref = (props) => { export const Pref = (props) => {
const justifyContent = props.alignTop ? "normal" : "center"; const justifyContent = props.alignTop ? "normal" : "center";
@ -24,7 +22,7 @@ export const Pref = (props) => {
flex: "1 0 40%", flex: "1 0 40%",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
justifyContent: justifyContent, justifyContent,
paddingRight: "30px", paddingRight: "30px",
}} }}
> >
@ -44,7 +42,7 @@ export const Pref = (props) => {
flex: "1 0 calc(60% - 50px)", flex: "1 0 calc(60% - 50px)",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
justifyContent: justifyContent, justifyContent,
}} }}
> >
{props.children} {props.children}

View file

@ -15,54 +15,65 @@ import {
TableRow, TableRow,
Tooltip, Tooltip,
useMediaQuery, useMediaQuery,
Typography,
IconButton,
Container,
TextField,
MenuItem,
Card,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material"; } 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 EditIcon from "@mui/icons-material/Edit";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import IconButton from "@mui/material/IconButton";
import 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 { useTranslation } from "react-i18next";
import { Info } from "@mui/icons-material";
import { useOutletContext } from "react-router-dom";
import theme from "./theme"; 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 userManager from "../app/UserManager";
import { playSound, shuffle, sounds, validUrl } from "../app/utils"; import { playSound, shuffle, sounds, validUrl } from "../app/utils";
import { useTranslation } from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi, { Permission, Role } from "../app/AccountApi"; import accountApi, { Permission, Role } from "../app/AccountApi";
import { Pref, PrefGroup } from "./Pref"; import { Pref, PrefGroup } from "./Pref";
import { Info } from "@mui/icons-material";
import { AccountContext } from "./App"; import { AccountContext } from "./App";
import { useOutletContext } from "react-router-dom"; import { Paragraph } from "./styles";
import prefs from "../app/Prefs";
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors"; import { UnauthorizedError } from "../app/errors";
import subscriptionManager from "../app/SubscriptionManager";
import { subscribeTopic } from "./SubscribeDialog"; import { subscribeTopic } from "./SubscribeDialog";
const Preferences = () => { const maybeUpdateAccountSettings = async (payload) => {
return ( if (!session.exists()) {
<Container maxWidth="md" sx={{ marginTop: 3, marginBottom: 3 }}> return;
<Stack spacing={3}> }
<Notifications /> try {
<Reservations /> await accountApi.updateSettings(payload);
<Users /> } catch (e) {
<Appearance /> console.log(`[Preferences] Error updating account settings`, e);
</Stack> if (e instanceof UnauthorizedError) {
</Container> session.resetAndRedirect(routes.login);
); }
}
}; };
const Preferences = () => (
<Container maxWidth="md" sx={{ marginTop: 3, marginBottom: 3 }}>
<Stack spacing={3}>
<Notifications />
<Reservations />
<Users />
<Appearance />
</Stack>
</Container>
);
const Notifications = () => { const Notifications = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@ -107,7 +118,7 @@ const Sound = () => {
<div style={{ display: "flex", width: "100%" }}> <div style={{ display: "flex", width: "100%" }}>
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}> <FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
<Select value={sound} onChange={handleChange} aria-labelledby={labelId}> <Select value={sound} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem> <MenuItem value="none">{t("prefs_notifications_sound_no_sound")}</MenuItem>
{Object.entries(sounds).map((s) => ( {Object.entries(sounds).map((s) => (
<MenuItem key={s[0]} value={s[0]}> <MenuItem key={s[0]} value={s[0]}>
{s[1].label} {s[1].label}
@ -183,10 +194,12 @@ const DeleteAfter = () => {
}, },
}); });
}; };
if (deleteAfter === null || deleteAfter === undefined) { if (deleteAfter === null || deleteAfter === undefined) {
// !deleteAfter will not work with "0" // !deleteAfter will not work with "0"
return null; // While loading return null; // While loading
} }
const description = (() => { const description = (() => {
switch (deleteAfter) { switch (deleteAfter) {
case 0: case 0:
@ -199,8 +212,11 @@ const DeleteAfter = () => {
return t("prefs_notifications_delete_after_one_week_description"); return t("prefs_notifications_delete_after_one_week_description");
case 2592000: case 2592000:
return t("prefs_notifications_delete_after_one_month_description"); return t("prefs_notifications_delete_after_one_month_description");
default:
return "";
} }
})(); })();
return ( return (
<Pref labelId={labelId} title={t("prefs_notifications_delete_after_title")} description={description}> <Pref labelId={labelId} title={t("prefs_notifications_delete_after_title")} description={description}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}> <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
@ -245,7 +261,7 @@ const Users = () => {
</Typography> </Typography>
<Paragraph> <Paragraph>
{t("prefs_users_description")} {t("prefs_users_description")}
{session.exists() && <>{" " + t("prefs_users_description_no_sync")}</>} {session.exists() && <>{` ${t("prefs_users_description_no_sync")}`}</>}
</Paragraph> </Paragraph>
{users?.length > 0 && <UserTable users={users} />} {users?.length > 0 && <UserTable users={users} />}
</CardContent> </CardContent>
@ -371,9 +387,9 @@ const UserDialog = (props) => {
})(); })();
const handleSubmit = async () => { const handleSubmit = async () => {
props.onSubmit({ props.onSubmit({
baseUrl: baseUrl, baseUrl,
username: username, username,
password: password, password,
}); });
}; };
useEffect(() => { useEffect(() => {
@ -479,7 +495,7 @@ const Language = () => {
const showFlags = !navigator.userAgent.includes("Windows"); const showFlags = !navigator.userAgent.includes("Windows");
let title = t("prefs_appearance_language_title"); let title = t("prefs_appearance_language_title");
if (showFlags) { if (showFlags) {
title += " " + randomFlags.join(" "); title += ` ${randomFlags.join(" ")}`;
} }
const handleChange = async (ev) => { const handleChange = async (ev) => {
@ -676,18 +692,4 @@ const ReservationsTable = (props) => {
); );
}; };
const maybeUpdateAccountSettings = async (payload) => {
if (!session.exists()) {
return;
}
try {
await accountApi.updateSettings(payload);
} catch (e) {
console.log(`[Preferences] Error updating account settings`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
};
export default Preferences; export default Preferences;

View file

@ -1,30 +1,40 @@
import * as React from "react"; import * as React from "react";
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState } from "react";
import theme from "./theme"; import {
import { Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, Tooltip, useMediaQuery } from "@mui/material"; Checkbox,
import TextField from "@mui/material/TextField"; Chip,
FormControl,
FormControlLabel,
InputLabel,
Link,
Select,
Tooltip,
useMediaQuery,
TextField,
Dialog,
DialogTitle,
DialogContent,
Button,
Typography,
IconButton,
MenuItem,
Box,
} from "@mui/material";
import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
import { Close } from "@mui/icons-material";
import { Trans, useTranslation } from "react-i18next";
import priority1 from "../img/priority-1.svg"; import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg"; import priority2 from "../img/priority-2.svg";
import priority3 from "../img/priority-3.svg"; import priority3 from "../img/priority-3.svg";
import priority4 from "../img/priority-4.svg"; import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg"; import priority5 from "../img/priority-5.svg";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
import { Close } from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem";
import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils"; import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils";
import Box from "@mui/material/Box";
import AttachmentIcon from "./AttachmentIcon"; import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import api from "../app/Api"; import api from "../app/Api";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker"; import EmojiPicker from "./EmojiPicker";
import { Trans, useTranslation } from "react-i18next"; import theme from "./theme";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
@ -137,7 +147,7 @@ const PublishDialog = (props) => {
if (attachFile && message.trim()) { if (attachFile && message.trim()) {
url.searchParams.append("message", message.replaceAll("\n", "\\n").trim()); url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
} }
const body = attachFile ? attachFile : message; const body = attachFile || message;
try { try {
const user = await userManager.get(baseUrl); const user = await userManager.get(baseUrl);
const headers = maybeWithAuth({}, user); const headers = maybeWithAuth({}, user);
@ -171,32 +181,33 @@ const PublishDialog = (props) => {
const checkAttachmentLimits = async (file) => { const checkAttachmentLimits = async (file) => {
try { try {
const account = await accountApi.get(); const apiAccount = await accountApi.get();
const fileSizeLimit = account.limits.attachment_file_size ?? 0; const fileSizeLimit = apiAccount.limits.attachment_file_size ?? 0;
const remainingBytes = account.stats.attachment_total_size_remaining; const remainingBytes = apiAccount.stats.attachment_total_size_remaining;
const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit; const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;
const quotaReached = remainingBytes > 0 && file.size > remainingBytes; const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
if (fileSizeLimitReached && quotaReached) { if (fileSizeLimitReached && quotaReached) {
return setAttachFileError( setAttachFileError(
t("publish_dialog_attachment_limits_file_and_quota_reached", { t("publish_dialog_attachment_limits_file_and_quota_reached", {
fileSizeLimit: formatBytes(fileSizeLimit), fileSizeLimit: formatBytes(fileSizeLimit),
remainingBytes: formatBytes(remainingBytes), remainingBytes: formatBytes(remainingBytes),
}) })
); );
} else if (fileSizeLimitReached) { } else if (fileSizeLimitReached) {
return setAttachFileError( setAttachFileError(
t("publish_dialog_attachment_limits_file_reached", { t("publish_dialog_attachment_limits_file_reached", {
fileSizeLimit: formatBytes(fileSizeLimit), fileSizeLimit: formatBytes(fileSizeLimit),
}) })
); );
} else if (quotaReached) { } else if (quotaReached) {
return setAttachFileError( setAttachFileError(
t("publish_dialog_attachment_limits_quota_reached", { t("publish_dialog_attachment_limits_quota_reached", {
remainingBytes: formatBytes(remainingBytes), remainingBytes: formatBytes(remainingBytes),
}) })
); );
} else {
setAttachFileError("");
} }
setAttachFileError("");
} catch (e) { } catch (e) {
console.log(`[PublishDialog] Retrieving attachment limits failed`, e); console.log(`[PublishDialog] Retrieving attachment limits failed`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
@ -211,6 +222,13 @@ const PublishDialog = (props) => {
attachFileInput.current.click(); attachFileInput.current.click();
}; };
const updateAttachFile = async (file) => {
setAttachFile(file);
setFilename(file.name);
props.onResetOpenMode();
await checkAttachmentLimits(file);
};
const handleAttachFileChanged = async (ev) => { const handleAttachFileChanged = async (ev) => {
await updateAttachFile(ev.target.files[0]); await updateAttachFile(ev.target.files[0]);
}; };
@ -221,13 +239,6 @@ const PublishDialog = (props) => {
await updateAttachFile(ev.dataTransfer.files[0]); await updateAttachFile(ev.dataTransfer.files[0]);
}; };
const updateAttachFile = async (file) => {
setAttachFile(file);
setFilename(file.name);
props.onResetOpenMode();
await checkAttachmentLimits(file);
};
const handleAttachFileDragLeave = () => { const handleAttachFileDragLeave = () => {
setDropZone(false); setDropZone(false);
if (props.openMode === PublishDialog.OPEN_MODE_DRAG) { if (props.openMode === PublishDialog.OPEN_MODE_DRAG) {
@ -240,7 +251,7 @@ const PublishDialog = (props) => {
}; };
const handleEmojiPick = (emoji) => { const handleEmojiPick = (emoji) => {
setTags((tags) => (tags.trim() ? `${tags.trim()}, ${emoji}` : emoji)); setTags((prevTags) => (prevTags.trim() ? `${prevTags.trim()}, ${emoji}` : emoji));
}; };
const handleEmojiClose = () => { const handleEmojiClose = () => {
@ -372,23 +383,23 @@ const PublishDialog = (props) => {
"aria-label": t("publish_dialog_priority_label"), "aria-label": t("publish_dialog_priority_label"),
}} }}
> >
{[5, 4, 3, 2, 1].map((priority) => ( {[5, 4, 3, 2, 1].map((p) => (
<MenuItem <MenuItem
key={`priorityMenuItem${priority}`} key={`priorityMenuItem${p}`}
value={priority} value={p}
aria-label={t("notifications_priority_x", { aria-label={t("notifications_priority_x", {
priority: priority, priority: p,
})} })}
> >
<div style={{ display: "flex", alignItems: "center" }}> <div style={{ display: "flex", alignItems: "center" }}>
<img <img
src={priorities[priority].file} src={priorities[p].file}
style={{ marginRight: "8px" }} style={{ marginRight: "8px" }}
alt={t("notifications_priority_x", { alt={t("notifications_priority_x", {
priority: priority, priority: p,
})} })}
/> />
<div>{priorities[priority].label}</div> <div>{priorities[p].label}</div>
</div> </div>
</MenuItem> </MenuItem>
))} ))}
@ -466,8 +477,8 @@ const PublishDialog = (props) => {
"aria-label": t("publish_dialog_call_label"), "aria-label": t("publish_dialog_call_label"),
}} }}
> >
{account?.phone_numbers?.map((phoneNumber, i) => ( {account?.phone_numbers?.map((phoneNumber) => (
<MenuItem key={`phoneNumberMenuItem${i}`} value={phoneNumber} aria-label={phoneNumber}> <MenuItem key={phoneNumber} value={phoneNumber} aria-label={phoneNumber}>
{t("publish_dialog_call_item", { number: phoneNumber })} {t("publish_dialog_call_item", { number: phoneNumber })}
</MenuItem> </MenuItem>
))} ))}
@ -533,7 +544,7 @@ const PublishDialog = (props) => {
/> />
</ClosableRow> </ClosableRow>
)} )}
<input type="file" ref={attachFileInput} onChange={handleAttachFileChanged} style={{ display: "none" }} aria-hidden={true} /> <input type="file" ref={attachFileInput} onChange={handleAttachFileChanged} style={{ display: "none" }} aria-hidden />
{showAttachFile && ( {showAttachFile && (
<AttachmentBox <AttachmentBox
file={attachFile} file={attachFile}
@ -707,16 +718,14 @@ const PublishDialog = (props) => {
); );
}; };
const Row = (props) => { const Row = (props) => (
return ( <div style={{ display: "flex" }} role="row">
<div style={{ display: "flex" }} role="row"> {props.children}
{props.children} </div>
</div> );
);
};
const ClosableRow = (props) => { const ClosableRow = (props) => {
const closable = props.hasOwnProperty("closable") ? props.closable : true; const closable = props.closable !== undefined ? props.closable : true;
return ( return (
<Row> <Row>
{props.children} {props.children}
@ -748,7 +757,7 @@ const DialogIconButton = (props) => {
const AttachmentBox = (props) => { const AttachmentBox = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const file = props.file; const { file } = props;
return ( return (
<> <>
<Typography variant="body1" sx={{ marginTop: 2 }}> <Typography variant="body1" sx={{ marginTop: 2 }}>
@ -811,13 +820,7 @@ const ExpandingTextField = (props) => {
}, [props.value]); }, [props.value]);
return ( return (
<> <>
<Typography <Typography ref={invisibleFieldRef} component="span" variant={props.variant} aria-hidden sx={{ position: "absolute", left: "-200%" }}>
ref={invisibleFieldRef}
component="span"
variant={props.variant}
aria-hidden={true}
sx={{ position: "absolute", left: "-200%" }}
>
{props.value} {props.value}
</Typography> </Typography>
<TextField <TextField
@ -846,6 +849,7 @@ const DropArea = (props) => {
// This is where we could disallow certain files to be dragged in. // This is where we could disallow certain files to be dragged in.
// For now we allow all files. // For now we allow all files.
// eslint-disable-next-line no-param-reassign
ev.dataTransfer.dropEffect = "copy"; ev.dataTransfer.dropEffect = "copy";
ev.preventDefault(); ev.preventDefault();
}; };

View file

@ -1,24 +1,29 @@
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useState } from "react";
import Button from "@mui/material/Button"; import {
import TextField from "@mui/material/TextField"; Button,
import Dialog from "@mui/material/Dialog"; TextField,
import DialogContent from "@mui/material/DialogContent"; Dialog,
import DialogContentText from "@mui/material/DialogContentText"; DialogContent,
import DialogTitle from "@mui/material/DialogTitle"; DialogContentText,
import { Alert, FormControl, Select, useMediaQuery } from "@mui/material"; DialogTitle,
Alert,
FormControl,
Select,
useMediaQuery,
MenuItem,
ListItemIcon,
ListItemText,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { Check, DeleteForever } from "@mui/icons-material";
import theme from "./theme"; import theme from "./theme";
import { validTopic } from "../app/utils"; import { validTopic } from "../app/utils";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import { useTranslation } from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi, { Permission } from "../app/AccountApi"; import accountApi, { Permission } from "../app/AccountApi";
import ReserveTopicSelect from "./ReserveTopicSelect"; import ReserveTopicSelect from "./ReserveTopicSelect";
import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import { Check, DeleteForever } from "@mui/icons-material";
import { TopicReservedError, UnauthorizedError } from "../app/errors"; import { TopicReservedError, UnauthorizedError } from "../app/errors";
export const ReserveAddDialog = (props) => { export const ReserveAddDialog = (props) => {
@ -164,7 +169,7 @@ export const ReserveDeleteDialog = (props) => {
</ListItemIcon> </ListItemIcon>
<ListItemText primary={t("reservation_delete_dialog_action_keep_title")} /> <ListItemText primary={t("reservation_delete_dialog_action_keep_title")} />
</MenuItem> </MenuItem>
<MenuItem value={true}> <MenuItem value>
<ListItemIcon> <ListItemIcon>
<DeleteForever /> <DeleteForever />
</ListItemIcon> </ListItemIcon>

View file

@ -1,22 +1,14 @@
import * as React from "react"; import * as React from "react";
import { Lock, Public } from "@mui/icons-material"; import { Lock, Public } from "@mui/icons-material";
import Box from "@mui/material/Box"; import { Box } from "@mui/material";
export const PermissionReadWrite = React.forwardRef((props, ref) => { export const PermissionReadWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} ref={ref} {...props} />);
return <PermissionInternal icon={Public} ref={ref} {...props} />;
});
export const PermissionDenyAll = React.forwardRef((props, ref) => { export const PermissionDenyAll = React.forwardRef((props, ref) => <PermissionInternal icon={Lock} ref={ref} {...props} />);
return <PermissionInternal icon={Lock} ref={ref} {...props} />;
});
export const PermissionRead = React.forwardRef((props, ref) => { export const PermissionRead = React.forwardRef((props, ref) => <PermissionInternal icon={Public} text="R" ref={ref} {...props} />);
return <PermissionInternal icon={Public} text="R" ref={ref} {...props} />;
});
export const PermissionWrite = React.forwardRef((props, ref) => { export const PermissionWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} text="W" ref={ref} {...props} />);
return <PermissionInternal icon={Public} text="W" ref={ref} {...props} />;
});
const PermissionInternal = React.forwardRef((props, ref) => { const PermissionInternal = React.forwardRef((props, ref) => {
const size = props.size ?? "medium"; const size = props.size ?? "medium";

View file

@ -1,9 +1,6 @@
import * as React from "react"; import * as React from "react";
import { FormControl, Select } from "@mui/material"; import { FormControl, Select, MenuItem, ListItemIcon, ListItemText } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
import { Permission } from "../app/AccountApi"; import { Permission } from "../app/AccountApi";

View file

@ -1,19 +1,14 @@
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useState } from "react";
import TextField from "@mui/material/TextField"; import { TextField, Button, Box, Typography, InputAdornment, IconButton } from "@mui/material";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import routes from "./routes";
import session from "../app/Session";
import Typography from "@mui/material/Typography";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import AvatarBox from "./AvatarBox";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import accountApi from "../app/AccountApi";
import { InputAdornment } from "@mui/material";
import IconButton from "@mui/material/IconButton";
import { Visibility, VisibilityOff } from "@mui/icons-material"; import { Visibility, VisibilityOff } from "@mui/icons-material";
import accountApi from "../app/AccountApi";
import AvatarBox from "./AvatarBox";
import session from "../app/Session";
import routes from "./routes";
import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors"; import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors";
const Signup = () => { const Signup = () => {

View file

@ -1,12 +1,19 @@
import * as React from "react"; import * as React from "react";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import Button from "@mui/material/Button"; import {
import TextField from "@mui/material/TextField"; Button,
import Dialog from "@mui/material/Dialog"; TextField,
import DialogContent from "@mui/material/DialogContent"; Dialog,
import DialogContentText from "@mui/material/DialogContentText"; DialogContent,
import DialogTitle from "@mui/material/DialogTitle"; DialogContentText,
import { Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery } from "@mui/material"; DialogTitle,
Autocomplete,
Checkbox,
FormControlLabel,
FormGroup,
useMediaQuery,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import theme from "./theme"; import theme from "./theme";
import api from "../app/Api"; import api from "../app/Api";
import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils"; import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils";
@ -14,7 +21,6 @@ import userManager from "../app/UserManager";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller"; import poller from "../app/Poller";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import { useTranslation } from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi, { Permission, Role } from "../app/AccountApi"; import accountApi, { Permission, Role } from "../app/AccountApi";
@ -25,6 +31,21 @@ import { ReserveLimitChip } from "./SubscriptionPopup";
const publicBaseUrl = "https://ntfy.sh"; const publicBaseUrl = "https://ntfy.sh";
export const subscribeTopic = async (baseUrl, topic) => {
const subscription = await subscriptionManager.add(baseUrl, topic);
if (session.exists()) {
try {
await accountApi.addSubscription(baseUrl, topic);
} catch (e) {
console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
return subscription;
};
const SubscribeDialog = (props) => { const SubscribeDialog = (props) => {
const [baseUrl, setBaseUrl] = useState(""); const [baseUrl, setBaseUrl] = useState("");
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");
@ -33,7 +54,7 @@ const SubscribeDialog = (props) => {
const handleSuccess = async () => { const handleSuccess = async () => {
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
const actualBaseUrl = baseUrl ? baseUrl : config.base_url; const actualBaseUrl = baseUrl || config.base_url;
const subscription = await subscribeTopic(actualBaseUrl, topic); const subscription = await subscribeTopic(actualBaseUrl, topic);
poller.pollInBackground(subscription); // Dangle! poller.pollInBackground(subscription); // Dangle!
props.onSuccess(subscription); props.onSuccess(subscription);
@ -66,7 +87,7 @@ const SubscribePage = (props) => {
const [anotherServerVisible, setAnotherServerVisible] = useState(false); const [anotherServerVisible, setAnotherServerVisible] = useState(false);
const [everyone, setEveryone] = useState(Permission.DENY_ALL); const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url; const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url;
const topic = props.topic; const { topic } = props;
const existingTopicUrls = props.subscriptions.map((s) => topicUrl(s.baseUrl, s.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( const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])).filter(
(s) => s !== config.base_url (s) => s !== config.base_url
@ -86,14 +107,13 @@ const SubscribePage = (props) => {
if (user) { if (user) {
setError( setError(
t("subscribe_dialog_error_user_not_authorized", { t("subscribe_dialog_error_user_not_authorized", {
username: username, username,
}) })
); );
return; return;
} else {
props.onNeedsLogin();
return;
} }
props.onNeedsLogin();
return;
} }
// Reserve topic (if requested) // Reserve topic (if requested)
@ -125,10 +145,9 @@ const SubscribePage = (props) => {
if (anotherServerVisible) { if (anotherServerVisible) {
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic)); const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
} else {
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic));
return validTopic(topic) && !isExistingTopicUrl;
} }
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic));
return validTopic(topic) && !isExistingTopicUrl;
})(); })();
const updateBaseUrl = (ev, newVal) => { const updateBaseUrl = (ev, newVal) => {
@ -242,14 +261,14 @@ const LoginPage = (props) => {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const baseUrl = props.baseUrl ? props.baseUrl : config.base_url; const baseUrl = props.baseUrl ? props.baseUrl : config.base_url;
const topic = props.topic; const { topic } = props;
const handleLogin = async () => { const handleLogin = async () => {
const user = { baseUrl, username, password }; const user = { baseUrl, username, password };
const success = await api.topicAuth(baseUrl, topic, user); const success = await api.topicAuth(baseUrl, topic, user);
if (!success) { if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
setError(t("subscribe_dialog_error_user_not_authorized", { username: username })); setError(t("subscribe_dialog_error_user_not_authorized", { username }));
return; return;
} }
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
@ -298,19 +317,4 @@ const LoginPage = (props) => {
); );
}; };
export const subscribeTopic = async (baseUrl, topic) => {
const subscription = await subscriptionManager.add(baseUrl, topic);
if (session.exists()) {
try {
await accountApi.addSubscription(baseUrl, topic);
} catch (e) {
console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
}
}
return subscription;
};
export default SubscribeDialog; export default SubscribeDialog;

View file

@ -1,26 +1,32 @@
import * as React from "react"; import * as React from "react";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import Button from "@mui/material/Button"; import {
import TextField from "@mui/material/TextField"; Button,
import Dialog from "@mui/material/Dialog"; TextField,
import DialogContent from "@mui/material/DialogContent"; Dialog,
import DialogContentText from "@mui/material/DialogContentText"; DialogContent,
import DialogTitle from "@mui/material/DialogTitle"; DialogContentText,
import { Chip, InputAdornment, Portal, Snackbar, useMediaQuery } from "@mui/material"; DialogTitle,
Chip,
InputAdornment,
Portal,
Snackbar,
useMediaQuery,
MenuItem,
IconButton,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Clear } from "@mui/icons-material";
import theme from "./theme"; import theme from "./theme";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import { useTranslation } from "react-i18next";
import accountApi, { Role } from "../app/AccountApi"; import accountApi, { Role } from "../app/AccountApi";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import MenuItem from "@mui/material/MenuItem";
import PopupMenu from "./PopupMenu"; import PopupMenu from "./PopupMenu";
import { formatShortDateTime, shuffle } from "../app/utils"; import { formatShortDateTime, shuffle } from "../app/utils";
import api from "../app/Api"; import api from "../app/Api";
import { useNavigate } from "react-router-dom";
import IconButton from "@mui/material/IconButton";
import { Clear } from "@mui/icons-material";
import { AccountContext } from "./App"; import { AccountContext } from "./App";
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors"; import { UnauthorizedError } from "../app/errors";
@ -34,7 +40,7 @@ export const SubscriptionPopup = (props) => {
const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false); const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false);
const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false); const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false);
const [showPublishError, setShowPublishError] = useState(false); const [showPublishError, setShowPublishError] = useState(false);
const subscription = props.subscription; const { subscription } = props;
const placement = props.placement ?? "left"; const placement = props.placement ?? "left";
const reservations = account?.reservations || []; const reservations = account?.reservations || [];
@ -64,8 +70,8 @@ export const SubscriptionPopup = (props) => {
}; };
const handleSendTestMessage = async () => { const handleSendTestMessage = async () => {
const baseUrl = props.subscription.baseUrl; const { baseUrl } = props.subscription;
const topic = props.subscription.topic; const { topic } = props.subscription;
const tags = shuffle([ const tags = shuffle([
"grinning", "grinning",
"octopus", "octopus",
@ -110,9 +116,9 @@ export const SubscriptionPopup = (props) => {
])[0]; ])[0];
try { try {
await api.publish(baseUrl, topic, message, { await api.publish(baseUrl, topic, message, {
title: title, title,
priority: priority, priority,
tags: tags, tags,
}); });
} catch (e) { } catch (e) {
console.log(`[SubscriptionPopup] Error publishing message`, e); console.log(`[SubscriptionPopup] Error publishing message`, e);
@ -201,7 +207,7 @@ export const SubscriptionPopup = (props) => {
const DisplayNameDialog = (props) => { const DisplayNameDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const subscription = props.subscription; const { subscription } = props;
const [error, setError] = useState(""); const [error, setError] = useState("");
const [displayName, setDisplayName] = useState(subscription.displayName ?? ""); const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
@ -265,9 +271,11 @@ export const ReserveLimitChip = () => {
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) { if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) {
return <></>; return <></>;
} else if (config.enable_payments) { }
if (config.enable_payments) {
return account?.limits.reservations > 0 ? <LimitReachedChip /> : <ProChip />; return account?.limits.reservations > 0 ? <LimitReachedChip /> : <ProChip />;
} else if (account) { }
if (account) {
return <LimitReachedChip />; return <LimitReachedChip />;
} }
return <></>; return <></>;
@ -290,20 +298,17 @@ const LimitReachedChip = () => {
); );
}; };
export const ProChip = () => { export const ProChip = () => (
const { t } = useTranslation(); <Chip
return ( label="ntfy Pro"
<Chip variant="outlined"
label={"ntfy Pro"} color="primary"
variant="outlined" sx={{
color="primary" opacity: 0.8,
sx={{ fontWeight: "bold",
opacity: 0.8, borderWidth: "2px",
fontWeight: "bold", height: "24px",
borderWidth: "2px", marginLeft: "5px",
height: "24px", }}
marginLeft: "5px", />
}} );
/>
);
};

View file

@ -1,28 +1,64 @@
import * as React from "react"; import * as React from "react";
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import Dialog from "@mui/material/Dialog"; import {
import DialogContent from "@mui/material/DialogContent"; Dialog,
import DialogTitle from "@mui/material/DialogTitle"; DialogContent,
import { Alert, CardActionArea, CardContent, Chip, Link, ListItem, Switch, useMediaQuery } from "@mui/material"; DialogTitle,
import theme from "./theme"; Alert,
import Button from "@mui/material/Button"; CardActionArea,
import accountApi, { SubscriptionInterval } from "../app/AccountApi"; CardContent,
import session from "../app/Session"; Chip,
import routes from "./routes"; Link,
import Card from "@mui/material/Card"; ListItem,
import Typography from "@mui/material/Typography"; Switch,
import { AccountContext } from "./App"; useMediaQuery,
import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils"; Button,
Card,
Typography,
List,
ListItemIcon,
ListItemText,
Box,
DialogContentText,
DialogActions,
} from "@mui/material";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import List from "@mui/material/List";
import { Check, Close } from "@mui/icons-material"; import { Check, Close } from "@mui/icons-material";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Box from "@mui/material/Box";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { UnauthorizedError } from "../app/errors"; import { UnauthorizedError } from "../app/errors";
import DialogContentText from "@mui/material/DialogContentText"; import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils";
import DialogActions from "@mui/material/DialogActions"; import { AccountContext } from "./App";
import routes from "./routes";
import session from "../app/Session";
import accountApi, { SubscriptionInterval } from "../app/AccountApi";
import theme from "./theme";
const Feature = (props) => <FeatureItem feature>{props.children}</FeatureItem>;
const NoFeature = (props) => <FeatureItem feature={false}>{props.children}</FeatureItem>;
const FeatureItem = (props) => (
<ListItem disableGutters sx={{ m: 0, p: 0 }}>
<ListItemIcon sx={{ minWidth: "24px" }}>
{props.feature && <Check fontSize="small" sx={{ color: "#338574" }} />}
{!props.feature && <Close fontSize="small" sx={{ color: "gray" }} />}
</ListItemIcon>
<ListItemText sx={{ mt: "2px", mb: "2px" }} primary={<Typography variant="body1">{props.children}</Typography>} />
</ListItem>
);
const Action = {
REDIRECT_SIGNUP: 1,
CREATE_SUBSCRIPTION: 2,
UPDATE_SUBSCRIPTION: 3,
CANCEL_SUBSCRIPTION: 4,
};
const Banner = {
CANCEL_WARNING: 1,
PRORATION_INFO: 2,
RESERVATIONS_WARNING: 3,
};
const UpgradeDialog = (props) => { const UpgradeDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -52,7 +88,9 @@ const UpgradeDialog = (props) => {
const currentTierCode = currentTier?.code; // May be undefined const currentTierCode = currentTier?.code; // May be undefined
// Figure out buttons, labels and the submit action // Figure out buttons, labels and the submit action
let submitAction, submitButtonLabel, banner; let submitAction;
let submitButtonLabel;
let banner;
if (!account) { if (!account) {
submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup"); submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
submitAction = Action.REDIRECT_SIGNUP; submitAction = Action.REDIRECT_SIGNUP;
@ -112,18 +150,18 @@ const UpgradeDialog = (props) => {
}; };
// Figure out discount // Figure out discount
let discount = 0, let discount = 0;
upto = false; let upto = false;
if (newTier?.prices) { if (newTier?.prices) {
discount = Math.round(((newTier.prices.month * 12) / newTier.prices.year - 1) * 100); discount = Math.round(((newTier.prices.month * 12) / newTier.prices.year - 1) * 100);
} else { } else {
let n = 0; let n = 0;
for (const t of tiers) { for (const tier of tiers) {
if (t.prices) { if (tier.prices) {
const tierDiscount = Math.round(((t.prices.month * 12) / t.prices.year - 1) * 100); const tierDiscount = Math.round(((tier.prices.month * 12) / tier.prices.year - 1) * 100);
if (tierDiscount > discount) { if (tierDiscount > discount) {
discount = tierDiscount; discount = tierDiscount;
n++; n += 1;
} }
} }
} }
@ -157,8 +195,8 @@ const UpgradeDialog = (props) => {
<Chip <Chip
label={ label={
upto upto
? t("account_upgrade_dialog_interval_yearly_discount_save_up_to", { discount: discount }) ? t("account_upgrade_dialog_interval_yearly_discount_save_up_to", { discount })
: t("account_upgrade_dialog_interval_yearly_discount_save", { discount: discount }) : t("account_upgrade_dialog_interval_yearly_discount_save", { discount })
} }
color="primary" color="primary"
size="small" size="small"
@ -208,7 +246,7 @@ const UpgradeDialog = (props) => {
<Alert severity="warning" sx={{ fontSize: "1rem" }}> <Alert severity="warning" sx={{ fontSize: "1rem" }}>
<Trans <Trans
i18nKey="account_upgrade_dialog_reservations_warning" i18nKey="account_upgrade_dialog_reservations_warning"
count={account?.reservations.length - newTier?.limits.reservations} count={(account?.reservations.length ?? 0) - (newTier?.limits.reservations ?? 0)}
components={{ components={{
Link: <NavLink to={routes.settings} />, Link: <NavLink to={routes.settings} />,
}} }}
@ -269,9 +307,11 @@ const UpgradeDialog = (props) => {
const TierCard = (props) => { const TierCard = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const tier = props.tier; const { tier } = props;
let cardStyle, labelStyle, labelText; let cardStyle;
let labelStyle;
let labelText;
if (props.selected) { if (props.selected) {
cardStyle = { background: "#eee", border: "3px solid #338574" }; cardStyle = { background: "#eee", border: "3px solid #338574" };
labelStyle = { background: "#338574", color: "white" }; labelStyle = { background: "#338574", color: "white" };
@ -392,37 +432,4 @@ const TierCard = (props) => {
); );
}; };
const Feature = (props) => {
return <FeatureItem feature={true}>{props.children}</FeatureItem>;
};
const NoFeature = (props) => {
return <FeatureItem feature={false}>{props.children}</FeatureItem>;
};
const FeatureItem = (props) => {
return (
<ListItem disableGutters sx={{ m: 0, p: 0 }}>
<ListItemIcon sx={{ minWidth: "24px" }}>
{props.feature && <Check fontSize="small" sx={{ color: "#338574" }} />}
{!props.feature && <Close fontSize="small" sx={{ color: "gray" }} />}
</ListItemIcon>
<ListItemText sx={{ mt: "2px", mb: "2px" }} primary={<Typography variant="body1">{props.children}</Typography>} />
</ListItem>
);
};
const Action = {
REDIRECT_SIGNUP: 1,
CREATE_SUBSCRIPTION: 2,
UPDATE_SUBSCRIPTION: 3,
CANCEL_SUBSCRIPTION: 4,
};
const Banner = {
CANCEL_WARNING: 1,
PRORATION_INFO: 2,
RESERVATIONS_WARNING: 3,
};
export default UpgradeDialog; export default UpgradeDialog;

View file

@ -22,15 +22,6 @@ export const useConnectionListeners = (account, subscriptions, users) => {
// Register listeners for incoming messages, and connection state changes // Register listeners for incoming messages, and connection state changes
useEffect( useEffect(
() => { () => {
const handleMessage = async (subscriptionId, message) => {
const subscription = await subscriptionManager.get(subscriptionId);
if (subscription.internal) {
await handleInternalMessage(message);
} else {
await handleNotification(subscriptionId, message);
}
};
const handleInternalMessage = async (message) => { const handleInternalMessage = async (message) => {
console.log(`[ConnectionListener] Received message on sync topic`, message.message); console.log(`[ConnectionListener] Received message on sync topic`, message.message);
try { try {
@ -53,15 +44,33 @@ export const useConnectionListeners = (account, subscriptions, users) => {
await notifier.notify(subscriptionId, notification, defaultClickAction); await notifier.notify(subscriptionId, notification, defaultClickAction);
} }
}; };
const handleMessage = async (subscriptionId, message) => {
const subscription = await subscriptionManager.get(subscriptionId);
// Race condition: sometimes the subscription is already unsubscribed from account
// sync before the message is handled
if (!subscription) {
return;
}
if (subscription.internal) {
await handleInternalMessage(message);
} else {
await handleNotification(subscriptionId, message);
}
};
connectionManager.registerStateListener(subscriptionManager.updateState); connectionManager.registerStateListener(subscriptionManager.updateState);
connectionManager.registerMessageListener(handleMessage); connectionManager.registerMessageListener(handleMessage);
return () => { return () => {
connectionManager.resetStateListener(); connectionManager.resetStateListener();
connectionManager.resetMessageListener(); connectionManager.resetMessageListener();
}; };
}, },
// We have to disable dep checking for "navigate". This is fine, it never changes. // We have to disable dep checking for "navigate". This is fine, it never changes.
// eslint-disable-next-line
[] []
); );

View file

@ -1,7 +1,5 @@
import Typography from "@mui/material/Typography"; import { Typography, Container, Backdrop, styled } from "@mui/material";
import theme from "./theme"; import theme from "./theme";
import Container from "@mui/material/Container";
import { Backdrop, styled } from "@mui/material";
export const Paragraph = styled(Typography)({ export const Paragraph = styled(Typography)({
paddingTop: 8, paddingTop: 8,

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

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