forked from mirrors/ntfy
1
0
Fork 0

Compare commits

...

49 Commits

Author SHA1 Message Date
Vincent Batts 8eecd3c72a
Dockerfile*: use my images
Signed-off-by: Vincent Batts <vbatts@hashbangbash.com>
2023-06-03 15:33:31 -04:00
Vincent Batts 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
67 changed files with 1991 additions and 15792 deletions

3
.dockerignore Normal file
View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@ -11,23 +11,25 @@ import (
"heckel.io/ntfy/util"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
// Event type constants
const (
MessageEvent = "message"
KeepaliveEvent = "keepalive"
OpenEvent = "open"
PollRequestEvent = "poll_request"
// MessageEvent identifies a message event
MessageEvent = "message"
)
const (
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
type Client struct {
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,
// WithNoFirebase, and the generic WithHeader.
func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {
topicURL := c.expandTopicURL(topic)
req, _ := http.NewRequest("POST", topicURL, body)
topicURL, err := c.expandTopicURL(topic)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", topicURL, body)
if err != nil {
return nil, err
}
for _, option := range options {
if err := option(req); err != nil {
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.
// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam.
func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) {
topicURL, err := c.expandTopicURL(topic)
if err != nil {
return nil, err
}
ctx := context.Background()
messages := make([]*Message, 0)
msgChan := make(chan *Message)
errChan := make(chan error)
topicURL := c.expandTopicURL(topic)
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
options = append(options, WithPoll())
go func() {
@ -166,15 +177,18 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
// Example:
//
// c := client.New(client.NewConfig())
// subscriptionID := c.Subscribe("mytopic")
// subscriptionID, _ := c.Subscribe("mytopic")
// for m := range c.Messages {
// 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()
defer c.mu.Unlock()
subscriptionID := util.RandomString(10)
topicURL := c.expandTopicURL(topic)
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
ctx, cancel := context.WithCancel(context.Background())
c.subscriptions[subscriptionID] = &subscription{
@ -183,7 +197,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
cancel: cancel,
}
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
@ -199,31 +213,16 @@ func (c *Client) Unsubscribe(subscriptionID string) {
sub.cancel()
}
// UnsubscribeAll unsubscribes from a topic that has been previously subscribed with Subscribe.
// 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 {
func (c *Client) expandTopicURL(topic string) (string, error) {
if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") {
return topic
return topic, nil
} 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) {

View File

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

View File

@ -72,7 +72,7 @@ ntfy subscribe TOPIC COMMAND
$NTFY_TITLE $title, $t Message title
$NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
$NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
$NTFY_RAW $raw Raw JSON message
$NTFY_RAW $raw Raw JSON message
Examples:
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)
}
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...)
if err != nil {
return err
}
if s.Command != "" {
cmds[subscriptionID] = s.Command
} else if conf.DefaultCommand != "" {
@ -204,7 +207,10 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
}
}
if topic != "" {
subscriptionID := cl.Subscribe(topic, options...)
subscriptionID, err := cl.Subscribe(topic, options...)
if err != nil {
return err
}
cmds[subscriptionID] = command
}
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.
### 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
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)
**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)
* 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))
* 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 != "" {
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)
}
} else {

View File

@ -219,7 +219,7 @@ func TestServer_StaticSites(t *testing.T) {
rr = request(t, s, "GET", "/mytopic", "", nil)
require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow"/>`)
require.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow" />`)
rr = request(t, s, "GET", "/docs", "", nil)
require.Equal(t, 301, rr.Code)
@ -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 {
conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345"

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/
dist/
public/static/langs/
src/app/emojis.js

View File

@ -15,7 +15,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
<!-- Favicon, see favicon.io -->
<link rel="icon" type="image/png" href="%PUBLIC_URL%/static/images/favicon.ico" />
<link rel="icon" type="image/png" href="/static/images/favicon.ico" />
<!-- Previews in Google, Slack, WhatsApp, etc. -->
<meta property="og:type" content="website" />
@ -26,15 +26,15 @@
property="og:description"
content="ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy."
/>
<meta property="og:image" content="%PUBLIC_URL%/static/images/ntfy.png" />
<meta property="og:image" content="/static/images/ntfy.png" />
<meta property="og:url" content="https://ntfy.sh" />
<!-- Never index -->
<meta name="robots" content="noindex, nofollow" />
<!-- Style overrides & fonts -->
<link rel="stylesheet" href="%PUBLIC_URL%/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/app.css" type="text/css" />
<link rel="stylesheet" href="/static/css/fonts.css" type="text/css" />
</head>
<body>
<noscript>
@ -43,6 +43,7 @@
subscribe.
</noscript>
<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>
</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",
"private": true,
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"start": "NODE_OPTIONS=\"--enable-source-maps\" vite",
"build": "vite build",
"serve": "vite preview",
"format": "prettier . --write",
"format:check": "prettier . --check"
"format:check": "prettier . --check",
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/"
},
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.4.2",
"@mui/material": "latest",
"dexie": "^3.2.1",
@ -29,8 +31,16 @@
"stacktrace-js": "^2.0.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"eslint": "^8.41.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^2.8.8",
"react-scripts": "^5.0.0"
"vite": "^4.3.8"
},
"browserslist": {
"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_tier_features_reservations_one": "{{reservations}} rezervované téma",
"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_tier_features_messages_one": "{{messages}} mensaje 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_tier_price_per_month": "mois",
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} prélevé annuellement. Économisez {{save}}.",
"account_upgrade_dialog_billing_contact_email": "Pour des questions concernant la facturation, merci de nous <Link>contacter</Link> directement."
"account_upgrade_dialog_billing_contact_email": "Pour des questions concernant la facturation, merci de nous <Link>contacter</Link> directement.",
"publish_dialog_call_label": "Appel téléphonique",
"account_basics_phone_numbers_title": "Numéros de téléphone",
"account_basics_phone_numbers_dialog_description": "Pour utiliser la fonctionnalité de notification par appels, vous devez ajouter et vérifier au moins un numéro de téléphone. La vérification peut se faire par SMS ou appel téléphonique.",
"account_basics_phone_numbers_description": "Pour des notifications par appel téléphoniques",
"account_basics_phone_numbers_no_phone_numbers_yet": "Pas encore de numéros de téléphone",
"account_basics_phone_numbers_copied_to_clipboard": "Numéro de téléphone copié dans le presse-papier",
"account_basics_phone_numbers_dialog_title": "Ajouter un numéro de téléphone",
"account_basics_phone_numbers_dialog_number_label": "Numéro de téléphone",
"account_basics_phone_numbers_dialog_number_placeholder": "Ex : +33701020304",
"account_basics_phone_numbers_dialog_verify_button_sms": "Envoyer un SMS",
"account_basics_phone_numbers_dialog_verify_button_call": "Appelez moi",
"account_basics_phone_numbers_dialog_code_label": "Code de vérification",
"account_basics_phone_numbers_dialog_code_placeholder": "Ex : 123456",
"account_basics_phone_numbers_dialog_check_verification_button": "Code de confirmarion",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "Appel",
"account_usage_calls_none": "Aucun appels téléphoniques ne peut être fait avec ce compte",
"publish_dialog_call_reset": "Supprimer les appels téléphoniques",
"publish_dialog_chip_call_label": "Appel téléphonique"
}

View File

@ -379,5 +379,7 @@
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} panggilan telepon harian",
"account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon",
"account_basics_phone_numbers_dialog_code_label": "Kode verifikasi"
"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_access_header": "Toegang",
"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",
"action_bar_reservation_add": "Reservar tópico",
"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_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_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_emails_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 {
accountBillingPortalUrl,
accountBillingSubscriptionUrl,
@ -17,7 +18,6 @@ import {
} from "./utils";
import session from "./Session";
import subscriptionManager from "./SubscriptionManager";
import i18n from "i18next";
import prefs from "./Prefs";
import routes from "../components/routes";
import { fetchOrThrow, UnauthorizedError } from "./errors";
@ -66,13 +66,13 @@ class AccountApi {
async create(username, password) {
const url = accountUrl(config.base_url);
const body = JSON.stringify({
username: username,
password: password,
username,
password,
});
console.log(`[AccountApi] Creating user account ${url}`);
await fetchOrThrow(url, {
method: "POST",
body: body,
body,
});
}
@ -97,7 +97,7 @@ class AccountApi {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
password: password,
password,
}),
});
}
@ -118,7 +118,7 @@ class AccountApi {
async createToken(label, expires) {
const url = accountTokenUrl(config.base_url);
const body = {
label: label,
label,
expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0,
};
console.log(`[AccountApi] Creating user access token ${url}`);
@ -132,8 +132,8 @@ class AccountApi {
async updateToken(token, label, expires) {
const url = accountTokenUrl(config.base_url);
const body = {
token: token,
label: label,
token,
label,
};
if (expires > 0) {
body.expires = Math.floor(Date.now() / 1000) + expires;
@ -171,7 +171,7 @@ class AccountApi {
await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: body,
body,
});
}
@ -179,13 +179,13 @@ class AccountApi {
const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify({
base_url: baseUrl,
topic: topic,
topic,
});
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: body,
body,
});
const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription);
@ -196,14 +196,14 @@ class AccountApi {
const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify({
base_url: baseUrl,
topic: topic,
topic,
...payload,
});
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
const response = await fetchOrThrow(url, {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
body: body,
body,
});
const subscription = await response.json(); // May throw SyntaxError
console.log(`[AccountApi] Subscription`, subscription);
@ -230,8 +230,8 @@ class AccountApi {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
topic: topic,
everyone: everyone,
topic,
everyone,
}),
});
}
@ -261,25 +261,25 @@ class AccountApi {
async createBillingSubscription(tier, 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) {
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) {
const url = accountBillingSubscriptionUrl(config.base_url);
const response = await fetchOrThrow(url, {
method: method,
method,
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
tier: tier,
interval: interval,
tier,
interval,
}),
});
return await response.json(); // May throw SyntaxError
return response.json(); // May throw SyntaxError
}
async deleteBillingSubscription() {
@ -298,7 +298,7 @@ class AccountApi {
method: "POST",
headers: withBearerAuth({}, session.token()),
});
return await response.json(); // May throw SyntaxError
return response.json(); // May throw SyntaxError
}
async verifyPhoneNumber(phoneNumber, channel) {
@ -309,7 +309,7 @@ class AccountApi {
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
channel: channel,
channel,
}),
});
}
@ -322,12 +322,12 @@ class AccountApi {
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
number: phoneNumber,
code: code,
code,
}),
});
}
async deletePhoneNumber(phoneNumber, code) {
async deletePhoneNumber(phoneNumber) {
const url = accountPhoneUrl(config.base_url);
console.log(`[AccountApi] Deleting phone number ${url}`);
await fetchOrThrow(url, {
@ -369,6 +369,7 @@ class AccountApi {
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
}
return undefined;
}
}

View File

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

View File

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

View File

@ -1,6 +1,9 @@
import Connection from "./Connection";
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).
*
@ -55,12 +58,10 @@ class ConnectionManager {
// Create and add new connections
subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
const subscriptionId = subscription.id;
const connectionId = subscription.connectionId;
const { connectionId } = subscription;
const added = !this.connections.get(connectionId);
if (added) {
const baseUrl = subscription.baseUrl;
const topic = subscription.topic;
const user = subscription.user;
const { baseUrl, topic, user } = subscription;
const since = subscription.last;
const connection = new Connection(
connectionId,
@ -69,8 +70,8 @@ class ConnectionManager {
topic,
user,
since,
(subscriptionId, notification) => this.notificationReceived(subscriptionId, notification),
(subscriptionId, state) => this.stateChanged(subscriptionId, state)
(subId, notification) => this.notificationReceived(subId, notification),
(subId, state) => this.stateChanged(subId, state)
);
this.connections.set(connectionId, connection);
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();
export default connectionManager;

View File

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

View File

@ -21,13 +21,16 @@ class Poller {
async pollAll() {
console.log(`[Poller] Polling all subscriptions`);
const subscriptions = await subscriptionManager.all();
for (const s of subscriptions) {
try {
await this.poll(s);
} catch (e) {
console.log(`[Poller] Error polling ${s.id}`, e);
}
}
await Promise.all(
subscriptions.map(async (s) => {
try {
await this.poll(s);
} catch (e) {
console.log(`[Poller] Error polling ${s.id}`, e);
}
})
);
}
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 */
async all() {
const subscriptions = await db.subscriptions.toArray();
await Promise.all(
subscriptions.map(async (s) => {
s.new = await db.notifications.where({ subscriptionId: s.id, new: 1 }).count();
})
return Promise.all(
subscriptions.map(async (s) => ({
...s,
new: await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(),
}))
);
return subscriptions;
}
async get(subscriptionId) {
return await db.subscriptions.get(subscriptionId);
return db.subscriptions.get(subscriptionId);
}
async add(baseUrl, topic, internal) {
@ -25,8 +25,8 @@ class SubscriptionManager {
}
const subscription = {
id: topicUrl(baseUrl, topic),
baseUrl: baseUrl,
topic: topic,
baseUrl,
topic,
mutedUntil: 0,
last: null,
internal: internal || false,
@ -39,36 +39,40 @@ class SubscriptionManager {
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
// Add remote subscriptions
let remoteIds = []; // = topicUrl(baseUrl, topic)
for (let i = 0; i < remoteSubscriptions.length; i++) {
const remote = remoteSubscriptions[i];
const local = await this.add(remote.base_url, remote.topic, false);
const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
await this.update(local.id, {
displayName: remote.display_name, // May be undefined
reservation: reservation, // May be null!
});
remoteIds.push(local.id);
}
const remoteIds = await Promise.all(
remoteSubscriptions.map(async (remote) => {
const local = await this.add(remote.base_url, remote.topic, false);
const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
await this.update(local.id, {
displayName: remote.display_name, // May be undefined
reservation, // May be null!
});
return local.id;
})
);
// Remove local subscriptions that do not exist remotely
const localSubscriptions = await db.subscriptions.toArray();
for (let i = 0; i < localSubscriptions.length; i++) {
const local = localSubscriptions[i];
const remoteExists = remoteIds.includes(local.id);
if (!local.internal && !remoteExists) {
await this.remove(local.id);
}
}
await Promise.all(
localSubscriptions.map(async (local) => {
const remoteExists = remoteIds.includes(local.id);
if (!local.internal && !remoteExists) {
await this.remove(local.id);
}
})
);
}
async updateState(subscriptionId, state) {
db.subscriptions.update(subscriptionId, { state: state });
db.subscriptions.update(subscriptionId, { state });
}
async remove(subscriptionId) {
await db.subscriptions.delete(subscriptionId);
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
await db.notifications.where({ subscriptionId }).delete();
}
async first() {
@ -101,8 +105,12 @@ class SubscriptionManager {
return false;
}
try {
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab
await db.notifications.add({
...notification,
subscriptionId,
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
new: 1,
}); // FIXME consider put() for double tab
await db.subscriptions.update(subscriptionId, {
last: notification.id,
});
@ -140,7 +148,7 @@ class SubscriptionManager {
}
async deleteNotifications(subscriptionId) {
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
await db.notifications.where({ subscriptionId }).delete();
}
async markNotificationRead(notificationId) {
@ -148,24 +156,24 @@ class SubscriptionManager {
}
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) {
await db.subscriptions.update(subscriptionId, {
mutedUntil: mutedUntil,
mutedUntil,
});
}
async setDisplayName(subscriptionId, displayName) {
await db.subscriptions.update(subscriptionId, {
displayName: displayName,
displayName,
});
}
async setReservation(subscriptionId, reservation) {
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,
// 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
export const fetchOrThrow = async (url, options) => {
const response = await fetch(url, options);
if (response.status !== 200) {
await throwAppError(response);
const maybeToJson = async (response) => {
try {
return await response.json();
} 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) => {
if (response.status === 401 || response.status === 403) {
console.log(`[Error] HTTP ${response.status}`, response);
@ -32,44 +71,10 @@ export const throwAppError = async (response) => {
throw new Error(`Unexpected response ${response.status}`);
};
const maybeToJson = async (response) => {
try {
return await response.json();
} catch (e) {
return null;
export const fetchOrThrow = async (url, options) => {
const response = await fetch(url, options);
if (response.status !== 200) {
await throwAppError(response);
}
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 beep from "../sounds/beep.mp3";
import juntos from "../sounds/juntos.mp3";
@ -7,8 +8,11 @@ import dadum from "../sounds/dadum.mp3";
import pop from "../sounds/pop.mp3";
import popSwoosh from "../sounds/pop-swoosh.mp3";
import config from "./config";
import { Base64 } from "js-base64";
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
export const expandSecureUrl = (url) => `https://${url}`;
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
export const topicUrlWs = (baseUrl, topic) =>
`${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://");
@ -28,14 +32,10 @@ export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
export const expandSecureUrl = (url) => `https://${url}`;
export const validUrl = (url) => {
return url.match(/^https?:\/\/.+/);
};
export const validUrl = (url) => url.match(/^https?:\/\/.+/);
export const disallowedTopic = (topic) => config.disallowed_topics.includes(topic);
export const validTopic = (topic) => {
if (disallowedTopic(topic)) {
@ -44,14 +44,11 @@ export const validTopic = (topic) => {
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) => {
if (subscription.displayName) {
return subscription.displayName;
} else if (subscription.baseUrl === config.base_url) {
}
if (subscription.baseUrl === config.base_url) {
return subscription.topic;
}
return topicShortUrl(subscription.baseUrl, subscription.topic);
@ -67,7 +64,15 @@ rawEmojis.forEach((emoji) => {
const toEmojis = (tags) => {
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) => {
@ -77,41 +82,31 @@ export const formatTitleWithDefault = (m, 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) => {
if (m.title) {
return m.message;
} else {
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.message}`;
} else {
return m.message;
}
}
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.message}`;
}
return m.message;
};
export const unmatchedTags = (tags) => {
if (!tags) return [];
else return tags.filter((tag) => !(tag in emojis));
return tags.filter((tag) => !(tag in emojis));
};
export const maybeWithAuth = (headers, user) => {
if (user && user.password) {
return withBasicAuth(headers, user.username, user.password);
} else if (user && user.token) {
return withBearerAuth(headers, user.token);
}
return headers;
};
export const encodeBase64 = (s) => Base64.encode(s);
export const encodeBase64Url = (s) => Base64.encodeURI(s);
export const bearerAuth = (token) => `Bearer ${token}`;
export const basicAuth = (username, password) => `Basic ${encodeBase64(`${username}:${password}`)}`;
export const withBearerAuth = (headers, token) => ({ ...headers, Authorization: bearerAuth(token) });
export const maybeWithBearerAuth = (headers, token) => {
if (token) {
@ -120,32 +115,18 @@ export const maybeWithBearerAuth = (headers, token) => {
return headers;
};
export const withBasicAuth = (headers, username, password) => {
headers["Authorization"] = basicAuth(username, password);
export const withBasicAuth = (headers, 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;
};
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) => {
const actionErrors = (notification.actions ?? [])
.map((action) => action.error)
@ -153,50 +134,47 @@ export const maybeAppendActionErrors = (message, notification) => {
.join("\n");
if (actionErrors.length === 0) {
return message;
} else {
return `${message}\n\n${actionErrors}`;
}
return `${message}\n\n${actionErrors}`;
};
export const shuffle = (arr) => {
let j, x;
for (let index = arr.length - 1; index > 0; index--) {
j = Math.floor(Math.random() * (index + 1));
x = arr[index];
arr[index] = arr[j];
arr[j] = x;
const returnArr = [...arr];
for (let index = returnArr.length - 1; index > 0; index -= 1) {
const j = Math.floor(Math.random() * (index + 1));
[returnArr[index], returnArr[j]] = [returnArr[j], returnArr[index]];
}
return arr;
return returnArr;
};
export const splitNoEmpty = (s, delimiter) => {
return s
export const splitNoEmpty = (s, delimiter) =>
s
.split(delimiter)
.map((x) => x.trim())
.filter((x) => x !== "");
};
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
export const hashCode = async (s) => {
let hash = 0;
for (let i = 0; i < s.length; i++) {
for (let i = 0; i < s.length; i += 1) {
const char = s.charCodeAt(i);
// eslint-disable-next-line no-bitwise
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
// eslint-disable-next-line no-bitwise
hash &= hash; // Convert to 32bit integer
}
return hash;
};
export const formatShortDateTime = (timestamp) => {
return new Intl.DateTimeFormat("default", {
export const formatShortDateTime = (timestamp) =>
new Intl.DateTimeFormat("default", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(timestamp * 1000));
};
export const formatShortDate = (timestamp) => {
return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000));
};
export const formatShortDate = (timestamp) => new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000));
export const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return "0 bytes";
@ -204,13 +182,14 @@ export const formatBytes = (bytes, decimals = 2) => {
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
};
export const formatNumber = (n) => {
if (n === 0) {
return n;
} else if (n % 1000 === 0) {
}
if (n % 1000 === 0) {
return `${n / 1000}k`;
}
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
// eslint-disable-next-line func-style
export async function* fetchLinesIterator(fileURL, headers) {
const utf8Decoder = new TextDecoder("utf-8");
const response = await fetch(fileURL, {
headers: headers,
headers,
});
const reader = response.body.getReader();
let { value: chunk, done: readerDone } = await reader.read();
@ -277,15 +257,18 @@ export async function* fetchLinesIterator(fileURL, headers) {
let startIndex = 0;
for (;;) {
let result = re.exec(chunk);
const result = re.exec(chunk);
if (!result) {
if (readerDone) {
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());
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : "");
startIndex = re.lastIndex = 0;
startIndex = 0;
re.lastIndex = 0;
// eslint-disable-next-line no-continue
continue;
}
yield chunk.substring(startIndex, result.index);
@ -299,7 +282,8 @@ export async function* fetchLinesIterator(fileURL, headers) {
export const randomAlphanumericString = (len) => {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
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];
}
return id;

View File

@ -21,42 +21,42 @@ import {
TableHead,
TableRow,
useMediaQuery,
Tooltip,
Typography,
Container,
Card,
Button,
Dialog,
DialogTitle,
DialogContent,
TextField,
IconButton,
MenuItem,
DialogContentText,
} from "@mui/material";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
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 session from "../app/Session";
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 { Pref, PrefGroup } from "./Pref";
import db from "../app/db";
import i18n from "i18next";
import humanizeDuration from "humanize-duration";
import UpgradeDialog from "./UpgradeDialog";
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 DialogFooter from "./DialogFooter";
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 { ProChip } from "./SubscriptionPopup";
import AddIcon from "@mui/icons-material/Add";
import theme from "./theme";
import session from "../app/Session";
const Account = () => {
if (!session.exists()) {
@ -439,23 +439,6 @@ const AddPhoneNumberDialog = (props) => {
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
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 () => {
try {
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 (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
@ -561,9 +561,7 @@ const Stats = () => {
return <></>;
}
const normalize = (value, max) => {
return Math.min((value / max) * 100, 100);
};
const normalize = (value, max) => Math.min((value / max) * 100, 100);
return (
<Card sx={{ p: 3 }} aria-label={t("account_usage_title")}>
@ -746,18 +744,16 @@ const Stats = () => {
);
};
const InfoIcon = () => {
return (
<InfoOutlinedIcon
sx={{
verticalAlign: "middle",
width: "18px",
marginLeft: "4px",
color: "gray",
}}
/>
);
};
const InfoIcon = () => (
<InfoOutlinedIcon
sx={{
verticalAlign: "middle",
width: "18px",
marginLeft: "4px",
color: "gray",
}}
/>
);
const Tokens = () => {
const { t } = useTranslation();
@ -775,10 +771,6 @@ const Tokens = () => {
setDialogOpen(false);
};
const handleDialogSubmit = async (user) => {
setDialogOpen(false);
//
};
return (
<Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
<CardContent sx={{ paddingBottom: 1 }}>
@ -814,7 +806,8 @@ const TokensTable = (props) => {
const tokens = (props.tokens || []).sort((a, b) => {
if (a.token === session.token()) {
return -1;
} else if (b.token === session.token()) {
}
if (b.token === session.token()) {
return 1;
}
return a.token.localeCompare(b.token);
@ -1025,7 +1018,7 @@ const TokenDeleteDialog = (props) => {
<Trans i18nKey="account_tokens_delete_dialog_description" />
</DialogContentText>
</DialogContent>
<DialogFooter status>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} color="error">
{t("account_tokens_delete_dialog_submit_button")}

View File

@ -1,29 +1,21 @@
import AppBar from "@mui/material/AppBar";
import Navigation from "./Navigation";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import { AppBar, Toolbar, IconButton, Typography, Box, MenuItem, Button, Divider, ListItemIcon } from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import Typography from "@mui/material/Typography";
import * as React from "react";
import { useState } from "react";
import Box from "@mui/material/Box";
import { topicDisplayName } from "../app/utils";
import db from "../app/db";
import { useLocation, useNavigate } from "react-router-dom";
import MenuItem from "@mui/material/MenuItem";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsIcon from "@mui/icons-material/Notifications";
import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
import routes from "./routes";
import subscriptionManager from "../app/SubscriptionManager";
import logo from "../img/ntfy.svg";
import { useTranslation } from "react-i18next";
import session from "../app/Session";
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import { Logout, Person, Settings } from "@mui/icons-material";
import ListItemIcon from "@mui/material/ListItemIcon";
import session from "../app/Session";
import logo from "../img/ntfy.svg";
import subscriptionManager from "../app/SubscriptionManager";
import routes from "./routes";
import db from "../app/db";
import { topicDisplayName } from "../app/utils";
import Navigation from "./Navigation";
import accountApi from "../app/AccountApi";
import PopupMenu from "./PopupMenu";
import { SubscriptionPopup } from "./SubscriptionPopup";
@ -86,7 +78,7 @@ const ActionBar = (props) => {
const SettingsIcons = (props) => {
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null);
const subscription = props.subscription;
const { subscription } = props;
const handleToggleMute = async () => {
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 { createContext, Suspense, useContext, useEffect, useState } from "react";
import Box from "@mui/material/Box";
import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react";
import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress } from "@mui/material";
import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import Toolbar from "@mui/material/Toolbar";
import { useLiveQuery } from "dexie-react-hooks";
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
import { AllSubscriptions, SingleSubscription } from "./Notifications";
import theme from "./theme";
import Navigation from "./Navigation";
import ActionBar from "./ActionBar";
import notifier from "../app/Notifier";
import Preferences from "./Preferences";
import { useLiveQuery } from "dexie-react-hooks";
import subscriptionManager from "../app/SubscriptionManager";
import userManager from "../app/UserManager";
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
import { expandUrl } from "../app/utils";
import ErrorBoundary from "./ErrorBoundary";
import routes from "./routes";
@ -21,7 +19,6 @@ import { useAccountListener, useBackgroundProcesses, useConnectionListeners } fr
import PublishDialog from "./PublishDialog";
import Messaging from "./Messaging";
import "./i18n"; // Translations!
import { Backdrop, CircularProgress } from "@mui/material";
import Login from "./Login";
import Signup from "./Signup";
import Account from "./Account";
@ -30,11 +27,13 @@ export const AccountContext = createContext(null);
const App = () => {
const [account, setAccount] = useState(null);
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
return (
<Suspense fallback={<Loader />}>
<BrowserRouter>
<ThemeProvider theme={theme}>
<AccountContext.Provider value={{ account, setAccount }}>
<AccountContext.Provider value={accountMemo}>
<CssBaseline />
<ErrorBoundary>
<Routes>
@ -56,6 +55,10 @@ const App = () => {
);
};
const updateTitle = (newNotificationsCount) => {
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
};
const Layout = () => {
const params = useParams();
const { account, setAccount } = useContext(AccountContext);
@ -66,12 +69,11 @@ const Layout = () => {
const subscriptions = useLiveQuery(() => subscriptionManager.all());
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
const [selected] = (subscriptionsWithoutInternal || []).filter((s) => {
return (
const [selected] = (subscriptionsWithoutInternal || []).filter(
(s) =>
(params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) ||
(config.base_url === s.baseUrl && params.topic === s.topic)
);
});
);
useConnectionListeners(account, subscriptions, users);
useAccountListener(setAccount);
@ -95,7 +97,7 @@ const Layout = () => {
<Outlet
context={{
subscriptions: subscriptionsWithoutInternal,
selected: selected,
selected,
}}
/>
</Main>
@ -104,41 +106,35 @@ const Layout = () => {
);
};
const Main = (props) => {
return (
<Box
id="main"
component="main"
sx={{
display: "flex",
flexGrow: 1,
flexDirection: "column",
padding: 3,
width: { sm: `calc(100% - ${Navigation.width}px)` },
height: "100vh",
overflow: "auto",
backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]),
}}
>
{props.children}
</Box>
);
};
const Main = (props) => (
<Box
id="main"
component="main"
sx={{
display: "flex",
flexGrow: 1,
flexDirection: "column",
padding: 3,
width: { sm: `calc(100% - ${Navigation.width}px)` },
height: "100vh",
overflow: "auto",
backgroundColor: ({ palette }) => (palette.mode === "light" ? palette.grey[100] : palette.grey[900]),
}}
>
{props.children}
</Box>
);
const Loader = () => (
<Backdrop
open={true}
open
sx={{
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 />
</Backdrop>
);
const updateTitle = (newNotificationsCount) => {
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
};
export default App;

View File

@ -1,16 +1,17 @@
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 fileImage from "../img/file-image.svg";
import fileVideo from "../img/file-video.svg";
import fileAudio from "../img/file-audio.svg";
import fileApp from "../img/file-app.svg";
import { useTranslation } from "react-i18next";
const AttachmentIcon = (props) => {
const { t } = useTranslation();
const type = props.type;
let imageFile, imageLabel;
const { type } = props;
let imageFile;
let imageLabel;
if (!type) {
imageFile = fileDocument;
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 { useRef, useState } from "react";
import Typography from "@mui/material/Typography";
import { rawEmojis } from "../app/emojis";
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField";
import { ClickAwayListener, Fade, InputAdornment, styled } from "@mui/material";
import IconButton from "@mui/material/IconButton";
import { Typography, Box, TextField, ClickAwayListener, Fade, InputAdornment, styled, IconButton, Popper } from "@mui/material";
import { Close } from "@mui/icons-material";
import Popper from "@mui/material/Popper";
import { splitNoEmpty } from "../app/utils";
import { useTranslation } from "react-i18next";
import { splitNoEmpty } from "../app/utils";
import { rawEmojis } from "../app/emojis";
// 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;
if (supportedEmoji) {
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);
}
} 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.emoji;
const { emoji } = props;
const matches = emojiMatches(emoji, props.search);
const title = `${emoji.description} (${emoji.aliases[0]})`;
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;

View File

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

View File

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

View File

@ -1,21 +1,18 @@
import * as React from "react";
import { useState } from "react";
import Navigation from "./Navigation";
import Paper from "@mui/material/Paper";
import IconButton from "@mui/material/IconButton";
import TextField from "@mui/material/TextField";
import { Paper, IconButton, TextField, Portal, Snackbar } from "@mui/material";
import SendIcon from "@mui/icons-material/Send";
import api from "../app/Api";
import PublishDialog from "./PublishDialog";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { Portal, Snackbar } from "@mui/material";
import { useTranslation } from "react-i18next";
import PublishDialog from "./PublishDialog";
import api from "../app/Api";
import Navigation from "./Navigation";
const Messaging = (props) => {
const [message, setMessage] = useState("");
const [dialogKey, setDialogKey] = useState(0);
const dialogOpenMode = props.dialogOpenMode;
const { dialogOpenMode } = props;
const subscription = props.selected;
const handleOpenDialogClick = () => {
@ -39,7 +36,7 @@ const Messaging = (props) => {
topic={subscription?.topic ?? ""}
message={message}
onClose={handleDialogClose}
onDragEnter={() => props.onDialogOpenModeChange((prev) => (prev ? prev : PublishDialog.OPEN_MODE_DRAG))} // Only update if not already open
onDragEnter={() => props.onDialogOpenModeChange((prev) => prev || PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open
onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}
/>
</>
@ -48,7 +45,7 @@ const Messaging = (props) => {
const MessageBar = (props) => {
const { t } = useTranslation();
const subscription = props.subscription;
const { subscription } = props;
const [snackOpen, setSnackOpen] = useState(false);
const handleSendClick = async () => {
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 { useContext, useState } from "react";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
import Person from "@mui/icons-material/Person";
import ListItemText from "@mui/material/ListItemText";
import Toolbar from "@mui/material/Toolbar";
import Divider from "@mui/material/Divider";
import List from "@mui/material/List";
import SettingsIcon from "@mui/icons-material/Settings";
import AddIcon from "@mui/icons-material/Add";
import { 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 { Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Portal, Tooltip } from "@mui/material";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
import routes from "./routes";
import { ConnectionState } from "../app/Connection";
import { useLocation, useNavigate } from "react-router-dom";
import subscriptionManager from "../app/SubscriptionManager";
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
import Box from "@mui/material/Box";
import notifier from "../app/Notifier";
import config from "../app/config";
import ArticleIcon from "@mui/icons-material/Article";
import { Trans, useTranslation } from "react-i18next";
import session from "../app/Session";
import accountApi, { Permission, Role } from "../app/AccountApi";
import CelebrationIcon from "@mui/icons-material/Celebration";
import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App";
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
import IconButton from "@mui/material/IconButton";
import { SubscriptionPopup } from "./SubscriptionPopup";
const navWidth = 280;
@ -85,6 +94,10 @@ const NavList = (props) => {
setSubscribeDialogKey((prev) => prev + 1);
};
const handleRequestNotificationPermission = () => {
notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted));
};
const handleSubscribeSubmit = (subscription) => {
console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
handleSubscribeReset();
@ -92,10 +105,6 @@ const NavList = (props) => {
handleRequestNotificationPermission();
};
const handleRequestNotificationPermission = () => {
notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted));
};
const handleAccountClick = () => {
accountApi.sync(); // Dangle!
navigate(routes.account);
@ -237,9 +246,7 @@ const UpgradeBanner = () => {
const SubscriptionList = (props) => {
const sortedSubscriptions = props.subscriptions
.filter((s) => !s.internal)
.sort((a, b) => {
return topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1;
});
.sort((a, b) => (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1));
return (
<>
{sortedSubscriptions.map((subscription) => (
@ -258,7 +265,7 @@ const SubscriptionItem = (props) => {
const navigate = useNavigate();
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
const subscription = props.subscription;
const { subscription } = props;
const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
const displayName = topicDisplayName(subscription);
const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName;

View File

@ -1,9 +1,29 @@
import Container from "@mui/material/Container";
import { ButtonBase, CardActions, CardContent, CircularProgress, Fade, Link, Modal, Snackbar, Stack, Tooltip } from "@mui/material";
import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography";
import {
Container,
ButtonBase,
CardActions,
CardContent,
CircularProgress,
Fade,
Link,
Modal,
Snackbar,
Stack,
Tooltip,
Card,
Typography,
IconButton,
Box,
Button,
} from "@mui/material";
import * as React from "react";
import { useEffect, useState } from "react";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import { useLiveQuery } from "dexie-react-hooks";
import InfiniteScroll from "react-infinite-scroll-component";
import { Trans, useTranslation } from "react-i18next";
import { useOutletContext } from "react-router-dom";
import {
formatBytes,
formatMessage,
@ -15,25 +35,23 @@ import {
topicShortUrl,
unmatchedTags,
} from "../app/utils";
import IconButton from "@mui/material/IconButton";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
import { useLiveQuery } from "dexie-react-hooks";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import subscriptionManager from "../app/SubscriptionManager";
import InfiniteScroll from "react-infinite-scroll-component";
import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import logoOutline from "../img/ntfy-outline.svg";
import AttachmentIcon from "./AttachmentIcon";
import { Trans, useTranslation } from "react-i18next";
import { useOutletContext } from "react-router-dom";
import { useAutoSubscribe } from "./hooks";
const priorityFiles = {
1: priority1,
2: priority2,
4: priority4,
5: priority5,
};
export const AllSubscriptions = () => {
const { subscriptions } = useOutletContext();
if (!subscriptions) {
@ -52,46 +70,50 @@ export const SingleSubscription = () => {
};
const AllSubscriptionsList = (props) => {
const subscriptions = props.subscriptions;
const { subscriptions } = props;
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
if (notifications === null || notifications === undefined) {
return <Loading />;
} else if (subscriptions.length === 0) {
}
if (subscriptions.length === 0) {
return <NoSubscriptions />;
} else if (notifications.length === 0) {
}
if (notifications.length === 0) {
return <NoNotificationsWithoutSubscription subscriptions={subscriptions} />;
}
return <NotificationList key="all" notifications={notifications} messageBar={false} />;
};
const SingleSubscriptionList = (props) => {
const subscription = props.subscription;
const { subscription } = props;
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
if (notifications === null || notifications === undefined) {
return <Loading />;
} else if (notifications.length === 0) {
}
if (notifications.length === 0) {
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 { t } = useTranslation();
const pageSize = 20;
const notifications = props.notifications;
const { notifications } = props;
const [snackOpen, setSnackOpen] = useState(false);
const [maxCount, setMaxCount] = useState(pageSize);
const count = Math.min(notifications.length, maxCount);
useEffect(() => {
return () => {
useEffect(
() => () => {
setMaxCount(pageSize);
const main = document.getElementById("main");
if (main) {
main.scrollTo(0, 0);
}
};
}, [props.id]);
},
[props.id]
);
return (
<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 { t } = useTranslation();
const notification = props.notification;
const attachment = notification.attachment;
const { notification } = props;
const { attachment } = notification;
const date = formatShortDateTime(notification.time);
const otherTags = unmatchedTags(notification.tags);
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 { t } = useTranslation();
const attachment = props.attachment;
const { attachment } = props;
const expired = attachment.expires && attachment.expires < Date.now() / 1000;
const expires = attachment.expires && attachment.expires > Date.now() / 1000;
const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/");
@ -402,66 +417,29 @@ const Image = (props) => {
);
};
const UserActions = (props) => {
return (
<>
{props.notification.actions.map((action) => (
<UserAction key={action.id} notification={props.notification} action={action} />
))}
</>
);
const UserActions = (props) => (
<>
{props.notification.actions.map((action) => (
<UserAction key={action.id} notification={props.notification} action={action} />
))}
</>
);
const ACTION_PROGRESS_ONGOING = 1;
const ACTION_PROGRESS_SUCCESS = 2;
const ACTION_PROGRESS_FAILED = 3;
const ACTION_LABEL_SUFFIX = {
[ACTION_PROGRESS_ONGOING]: " …",
[ACTION_PROGRESS_SUCCESS]: " ✔",
[ACTION_PROGRESS_FAILED]: " ❌",
};
const UserAction = (props) => {
const { t } = useTranslation();
const notification = props.notification;
const action = props.action;
if (action.action === "broadcast") {
return (
<Tooltip title={t("notifications_actions_not_supported")}>
<span>
<Button disabled aria-label={t("notifications_actions_not_supported")}>
{action.label}
</Button>
</span>
</Tooltip>
);
} else if (action.action === "view") {
return (
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
<Button
onClick={() => openUrl(action.url)}
aria-label={t("notifications_actions_open_url_title", {
url: action.url,
})}
>
{action.label}
</Button>
</Tooltip>
);
} else if (action.action === "http") {
const method = action.method ?? "POST";
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
return (
<Tooltip
title={t("notifications_actions_http_request_title", {
method: method,
url: action.url,
})}
>
<Button
onClick={() => performHttpAction(notification, action)}
aria-label={t("notifications_actions_http_request_title", {
method: method,
url: action.url,
})}
>
{label}
</Button>
</Tooltip>
);
}
return null; // Others
const updateActionStatus = (notification, action, progress, error) => {
subscriptionManager.updateNotification({
...notification,
actions: notification.actions.map((a) => (a.id === action.id ? { ...a, progress, error } : a)),
});
};
const performHttpAction = async (notification, action) => {
@ -488,29 +466,63 @@ const performHttpAction = async (notification, action) => {
}
};
const updateActionStatus = (notification, action, progress, error) => {
notification.actions = notification.actions.map((a) => {
if (a.id !== action.id) {
return a;
}
return { ...a, progress: progress, error: error };
});
subscriptionManager.updateNotification(notification);
};
const ACTION_PROGRESS_ONGOING = 1;
const ACTION_PROGRESS_SUCCESS = 2;
const ACTION_PROGRESS_FAILED = 3;
const ACTION_LABEL_SUFFIX = {
[ACTION_PROGRESS_ONGOING]: " …",
[ACTION_PROGRESS_SUCCESS]: " ✔",
[ACTION_PROGRESS_FAILED]: " ❌",
const UserAction = (props) => {
const { t } = useTranslation();
const { notification } = props;
const { action } = props;
if (action.action === "broadcast") {
return (
<Tooltip title={t("notifications_actions_not_supported")}>
<span>
<Button disabled aria-label={t("notifications_actions_not_supported")}>
{action.label}
</Button>
</span>
</Tooltip>
);
}
if (action.action === "view") {
return (
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
<Button
onClick={() => openUrl(action.url)}
aria-label={t("notifications_actions_open_url_title", {
url: action.url,
})}
>
{action.label}
</Button>
</Tooltip>
);
}
if (action.action === "http") {
const method = action.method ?? "POST";
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
return (
<Tooltip
title={t("notifications_actions_http_request_title", {
method,
url: action.url,
})}
>
<Button
onClick={() => performHttpAction(notification, action)}
aria-label={t("notifications_actions_http_request_title", {
method,
url: action.url,
})}
>
{label}
</Button>
</Tooltip>
);
}
return null; // Others
};
const NoNotifications = (props) => {
const { t } = useTranslation();
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
const topicShortUrlResolved = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
return (
<VerticallyCenteredContainer maxWidth="xs">
<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_example")}:<br />
<tt>$ curl -d "Hi" {shortUrl}</tt>
<tt>
{'$ curl -d "Hi" '}
{topicShortUrlResolved}
</tt>
</Paragraph>
<Paragraph>
<ForMoreDetails />
@ -533,7 +548,7 @@ const NoNotifications = (props) => {
const NoNotificationsWithoutSubscription = (props) => {
const { t } = useTranslation();
const subscription = props.subscriptions[0];
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
const topicShortUrlResolved = topicShortUrl(subscription.baseUrl, subscription.topic);
return (
<VerticallyCenteredContainer maxWidth="xs">
<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_example")}:<br />
<tt>$ curl -d "Hi" {shortUrl}</tt>
<tt>
{'$ curl -d "Hi" '}
{topicShortUrlResolved}
</tt>
</Paragraph>
<Paragraph>
<ForMoreDetails />
@ -574,17 +592,15 @@ const NoSubscriptions = () => {
);
};
const ForMoreDetails = () => {
return (
<Trans
i18nKey="notifications_more_details"
components={{
websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener" />,
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />,
}}
/>
);
};
const ForMoreDetails = () => (
<Trans
i18nKey="notifications_more_details"
components={{
websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener" />,
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />,
}}
/>
);
const Loading = () => {
const { t } = useTranslation();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,6 @@
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 MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
import { Permission } from "../app/AccountApi";

View File

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

View File

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

View File

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

View File

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

View File

@ -22,15 +22,6 @@ export const useConnectionListeners = (account, subscriptions, users) => {
// Register listeners for incoming messages, and connection state changes
useEffect(
() => {
const handleMessage = async (subscriptionId, message) => {
const subscription = await subscriptionManager.get(subscriptionId);
if (subscription.internal) {
await handleInternalMessage(message);
} else {
await handleNotification(subscriptionId, message);
}
};
const handleInternalMessage = async (message) => {
console.log(`[ConnectionListener] Received message on sync topic`, message.message);
try {
@ -53,15 +44,33 @@ export const useConnectionListeners = (account, subscriptions, users) => {
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.registerMessageListener(handleMessage);
return () => {
connectionManager.resetStateListener();
connectionManager.resetMessageListener();
};
},
// We have to disable dep checking for "navigate". This is fine, it never changes.
// eslint-disable-next-line
[]
);

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 Container from "@mui/material/Container";
import { Backdrop, styled } from "@mui/material";
export const Paragraph = styled(Typography)({
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()],
}));