Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
872bc6d307 | ||
|
|
c8f880c701 | ||
|
|
f2d3f0bdf9 | ||
|
|
9f8c63c7d5 | ||
|
|
2b5a1a7a1c | ||
|
|
499b2fb0d6 | ||
|
|
b7679c7826 | ||
|
|
ce01a66ff3 | ||
|
|
7582be1a39 | ||
|
|
f989fd0743 | ||
|
|
097e84aeed | ||
|
|
faadb5148f | ||
|
|
56ed4f0515 | ||
|
|
43981bb675 | ||
|
|
cd38511ad4 | ||
|
|
53f13fd811 | ||
|
|
77cc52e4ac | ||
|
|
35cb4606f6 | ||
|
|
d01ed355e0 | ||
|
|
495fb24b9a | ||
|
|
311ffc3672 | ||
|
|
7a1488fcd3 | ||
|
|
4267c0d9b6 | ||
|
|
88eb728fe3 | ||
|
|
26c835cdd1 | ||
|
|
7d3d697a20 | ||
|
|
798ee3c23c | ||
|
|
7581058c93 | ||
|
|
4f0ddfc30d | ||
|
|
0b918464c1 | ||
|
|
57bd37ef2f | ||
|
|
9fa1288dbc | ||
|
|
55eed868fa | ||
|
|
abb1baeecd | ||
|
|
5784b07f14 | ||
|
|
8e1e0b3740 | ||
|
|
3f42e0e945 | ||
|
|
9146e439d2 | ||
|
|
7a14a0b81f | ||
|
|
9247475ab2 | ||
|
|
6b4c04c390 | ||
|
|
e8216ae9e7 | ||
|
|
365a0b2832 | ||
|
|
f78389b6ef | ||
|
|
0d231d8bd9 | ||
|
|
d838790b8f | ||
|
|
9ce3545901 | ||
|
|
8db569e8a5 | ||
|
|
7d46f1eed9 | ||
|
|
7812eb9d19 |
2
.github/workflows/build.yaml
vendored
2
.github/workflows/build.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
name: Install Go
|
name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: '1.19.x'
|
go-version: '1.20.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
|
|||||||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
name: Install Go
|
name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: '1.19.x'
|
go-version: '1.20.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
|
|||||||
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
name: Install Go
|
name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: '1.19.x'
|
go-version: '1.20.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
|
|||||||
@@ -119,8 +119,6 @@ archives:
|
|||||||
- server/ntfy.service
|
- server/ntfy.service
|
||||||
- client/client.yml
|
- client/client.yml
|
||||||
- client/ntfy-client.service
|
- client/ntfy-client.service
|
||||||
replacements:
|
|
||||||
amd64: x86_64
|
|
||||||
-
|
-
|
||||||
id: ntfy_windows
|
id: ntfy_windows
|
||||||
builds:
|
builds:
|
||||||
@@ -131,8 +129,6 @@ archives:
|
|||||||
- LICENSE
|
- LICENSE
|
||||||
- README.md
|
- README.md
|
||||||
- client/client.yml
|
- client/client.yml
|
||||||
replacements:
|
|
||||||
amd64: x86_64
|
|
||||||
-
|
-
|
||||||
id: ntfy_darwin
|
id: ntfy_darwin
|
||||||
builds:
|
builds:
|
||||||
@@ -142,8 +138,6 @@ archives:
|
|||||||
- LICENSE
|
- LICENSE
|
||||||
- README.md
|
- README.md
|
||||||
- client/client.yml
|
- client/client.yml
|
||||||
replacements:
|
|
||||||
darwin: macOS
|
|
||||||
universal_binaries:
|
universal_binaries:
|
||||||
-
|
-
|
||||||
id: ntfy_darwin_all
|
id: ntfy_darwin_all
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
[](https://discord.gg/cT7ECsZj9w)
|
[](https://discord.gg/cT7ECsZj9w)
|
||||||
[](https://matrix.to/#/#ntfy:matrix.org)
|
[](https://matrix.to/#/#ntfy:matrix.org)
|
||||||
[](https://matrix.to/#/#ntfy-space:matrix.org)
|
[](https://matrix.to/#/#ntfy-space:matrix.org)
|
||||||
[](https://discuss.ntfy.sh/)
|
[](https://discuss.ntfy.sh/c/ntfy)
|
||||||
[](https://ntfy.statuspage.io/)
|
[](https://ntfy.statuspage.io/)
|
||||||
[](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
|
[](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
|
||||||
|
|
||||||
@@ -47,9 +47,8 @@ works best for you:
|
|||||||
|
|
||||||
* [Discord server](https://discord.gg/cT7ECsZj9w) - direct chat with the community
|
* [Discord server](https://discord.gg/cT7ECsZj9w) - direct chat with the community
|
||||||
* [Matrix room #ntfy](https://matrix.to/#/#ntfy:matrix.org) (+ [Matrix space](https://matrix.to/#/#ntfy-space:matrix.org)) - same chat, bridged from Discord
|
* [Matrix room #ntfy](https://matrix.to/#/#ntfy:matrix.org) (+ [Matrix space](https://matrix.to/#/#ntfy-space:matrix.org)) - same chat, bridged from Discord
|
||||||
* [Lemmy discussion board](https://discuss.ntfy.sh/) - asynchronous forum (_new as of June 2023_)
|
* [Lemmy discussion board](https://discuss.ntfy.sh/c/ntfy) - asynchronous forum (_new as of June 2023_)
|
||||||
* [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs
|
* [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs
|
||||||
* [Email](https://heckel.io/about) - reach me directly (_I usually prefer the other methods_)
|
|
||||||
|
|
||||||
## Announcements / beta testers
|
## Announcements / beta testers
|
||||||
For announcements of new releases and cutting-edge beta versions, please subscribe to the [ntfy.sh/announcements](https://ntfy.sh/announcements)
|
For announcements of new releases and cutting-edge beta versions, please subscribe to the [ntfy.sh/announcements](https://ntfy.sh/announcements)
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ func WithAttach(attach string) PublishOption {
|
|||||||
return WithHeader("X-Attach", attach)
|
return WithHeader("X-Attach", attach)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithMarkdown instructs the server to interpret the message body as Markdown
|
||||||
|
func WithMarkdown() PublishOption {
|
||||||
|
return WithHeader("X-Markdown", "yes")
|
||||||
|
}
|
||||||
|
|
||||||
// WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment
|
// WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment
|
||||||
func WithFilename(filename string) PublishOption {
|
func WithFilename(filename string) PublishOption {
|
||||||
return WithHeader("X-Filename", filename)
|
return WithHeader("X-Filename", filename)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ var flagsPublish = append(
|
|||||||
&cli.StringFlag{Name: "icon", Aliases: []string{"i"}, EnvVars: []string{"NTFY_ICON"}, Usage: "URL to use as notification icon"},
|
&cli.StringFlag{Name: "icon", Aliases: []string{"i"}, EnvVars: []string{"NTFY_ICON"}, Usage: "URL to use as notification icon"},
|
||||||
&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
|
&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
|
||||||
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
|
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
|
||||||
|
&cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"},
|
||||||
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
|
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
|
||||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
||||||
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
||||||
@@ -95,6 +96,7 @@ func execPublish(c *cli.Context) error {
|
|||||||
icon := c.String("icon")
|
icon := c.String("icon")
|
||||||
actions := c.String("actions")
|
actions := c.String("actions")
|
||||||
attach := c.String("attach")
|
attach := c.String("attach")
|
||||||
|
markdown := c.Bool("attach")
|
||||||
filename := c.String("filename")
|
filename := c.String("filename")
|
||||||
file := c.String("file")
|
file := c.String("file")
|
||||||
email := c.String("email")
|
email := c.String("email")
|
||||||
@@ -140,6 +142,9 @@ func execPublish(c *cli.Context) error {
|
|||||||
if attach != "" {
|
if attach != "" {
|
||||||
options = append(options, client.WithAttach(attach))
|
options = append(options, client.WithAttach(attach))
|
||||||
}
|
}
|
||||||
|
if markdown {
|
||||||
|
options = append(options, client.WithMarkdown())
|
||||||
|
}
|
||||||
if filename != "" {
|
if filename != "" {
|
||||||
options = append(options, client.WithFilename(filename))
|
options = append(options, client.WithFilename(filename))
|
||||||
}
|
}
|
||||||
|
|||||||
10
docs/faq.md
10
docs/faq.md
@@ -80,3 +80,13 @@ a proper backend. So as long as you secure your backend with ACLs, exposing the
|
|||||||
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier).
|
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier).
|
||||||
I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much
|
I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much
|
||||||
appreciated.
|
appreciated.
|
||||||
|
|
||||||
|
## Can I email you? Can I DM you on Discord/Matrix?
|
||||||
|
While I love chatting on [Discord](https://discord.gg/cT7ECsZj9w), [Matrix](https://matrix.to/#/#ntfy-space:matrix.org),
|
||||||
|
[Lemmy](https://discuss.ntfy.sh/c/ntfy), or [GitHub](https://github.com/binwiederhier/ntfy/issues), I generally
|
||||||
|
**do not respond to emails about ntfy or direct messages** about ntfy, unless you are paying for a
|
||||||
|
[ntfy Pro](https://ntfy.sh/#pricing) plan, or you are inquiring about business opportunities.
|
||||||
|
|
||||||
|
I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions
|
||||||
|
in the above-mentioned forums benefits others, since I can link to the discussion at a later point in time, or other users
|
||||||
|
may be able to help out. I hope you understand.
|
||||||
|
|||||||
@@ -29,37 +29,37 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_linux_x86_64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_amd64.tar.gz
|
||||||
tar zxvf ntfy_2.6.0_linux_x86_64.tar.gz
|
tar zxvf ntfy_2.6.2_linux_amd64.tar.gz
|
||||||
sudo cp -a ntfy_2.6.0_linux_x86_64/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.6.2_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_2.6.0_linux_armv6.tar.gz
|
tar zxvf ntfy_2.6.2_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_2.6.0_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.6.2_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_2.6.0_linux_armv7.tar.gz
|
tar zxvf ntfy_2.6.2_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_2.6.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.6.2_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_2.6.0_linux_arm64.tar.gz
|
tar zxvf ntfy_2.6.2_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_2.6.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.6.2_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_amd64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -117,7 +117,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_linux_armv6.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv6.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -125,7 +125,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv7.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -133,7 +133,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_arm64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -143,34 +143,36 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv6.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv7.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
## Arch Linux
|
## Arch Linux
|
||||||
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, build and install ntfy and keep it up to date.
|
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/).
|
||||||
|
You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download,
|
||||||
|
build and install ntfy and keep it up to date.
|
||||||
```
|
```
|
||||||
paru -S ntfysh-bin
|
paru -S ntfysh-bin
|
||||||
```
|
```
|
||||||
@@ -192,18 +194,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
|
|||||||
|
|
||||||
## macOS
|
## macOS
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_macOS_all.tar.gz),
|
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_darwin_all.tar.gz),
|
||||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||||
|
|
||||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_macOS_all.tar.gz > ntfy_2.6.0_macOS_all.tar.gz
|
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_darwin_all.tar.gz > ntfy_2.6.2_darwin_all.tar.gz
|
||||||
tar zxvf ntfy_2.6.0_macOS_all.tar.gz
|
tar zxvf ntfy_2.6.2_darwin_all.tar.gz
|
||||||
sudo cp -a ntfy_2.6.0_macOS_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.6.2_darwin_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/ntfy
|
mkdir ~/Library/Application\ Support/ntfy
|
||||||
cp ntfy_2.6.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
cp ntfy_2.6.2_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
ntfy --help
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -221,7 +223,7 @@ brew install ntfy
|
|||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.6.0/ntfy_2.6.0_windows_x86_64.zip),
|
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_windows_amd64.zip),
|
||||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||||
|
|
||||||
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||||
|
|||||||
@@ -125,9 +125,11 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
- [systemd-ntfy-poweronoff](https://github.com/stendler/systemd-ntfy-poweronoff) - Systemd services to send notifications on system startup and shutdown (Go)
|
- [systemd-ntfy-poweronoff](https://github.com/stendler/systemd-ntfy-poweronoff) - Systemd services to send notifications on system startup and shutdown (Go)
|
||||||
- [msgdrop](https://github.com/jbrubake/msgdrop) - Send and receive encrypted messages (Bash)
|
- [msgdrop](https://github.com/jbrubake/msgdrop) - Send and receive encrypted messages (Bash)
|
||||||
- [vigilant](https://github.com/VerifiedJoseph/vigilant) - Monitor RSS/ATOM and JSON feeds, and send push notifications on new entries (PHP)
|
- [vigilant](https://github.com/VerifiedJoseph/vigilant) - Monitor RSS/ATOM and JSON feeds, and send push notifications on new entries (PHP)
|
||||||
|
- [ansible-role-ntfy-alertmanager](https://github.com/bleetube/ansible-role-ntfy-alertmanager) - Ansible role to install xenrox/ntfy-alertmanager
|
||||||
|
|
||||||
## Blog + forum posts
|
## Blog + forum posts
|
||||||
|
|
||||||
|
- [Basic website monitoring using cronjobs and ntfy.sh](https://burkhardt.dev/2023/website-monitoring-cron-ntfy/) - burkhardt.dev - 6/2023
|
||||||
- [Pingdom alternative in one line of curl through ntfy.sh](https://piqoni.bearblog.dev/uptime-monitoring-in-one-line-of-curl/) - bearblog.dev - 6/2023
|
- [Pingdom alternative in one line of curl through ntfy.sh](https://piqoni.bearblog.dev/uptime-monitoring-in-one-line-of-curl/) - bearblog.dev - 6/2023
|
||||||
- [#OpenSourceDiscovery 78: ntfy.sh](https://opensourcedisc.substack.com/p/opensourcediscovery-78-ntfysh) - opensourcedisc.substack.com - 6/2023
|
- [#OpenSourceDiscovery 78: ntfy.sh](https://opensourcedisc.substack.com/p/opensourcediscovery-78-ntfysh) - opensourcedisc.substack.com - 6/2023
|
||||||
- [ntfy: des notifications instantanées](https://blogmotion.fr/diy/ntfy-notification-push-domotique-20708) - blogmotion.fr - 5/2023
|
- [ntfy: des notifications instantanées](https://blogmotion.fr/diy/ntfy-notification-push-domotique-20708) - blogmotion.fr - 5/2023
|
||||||
|
|||||||
108
docs/publish.md
108
docs/publish.md
@@ -138,7 +138,7 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an
|
|||||||
Tags = "warning,skull"
|
Tags = "warning,skull"
|
||||||
}
|
}
|
||||||
Body = "Remote access to phils-laptop detected. Act right away."
|
Body = "Remote access to phils-laptop detected. Act right away."
|
||||||
}
|
}
|
||||||
Invoke-RestMethod @Request
|
Invoke-RestMethod @Request
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -623,6 +623,109 @@ them with a comma, e.g. `tag1,tag2,tag3`.
|
|||||||
as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `tag1,=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
|
as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `tag1,=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
|
||||||
or `=?UTF-8?Q?=C3=84pfel?=,tag2` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
|
or `=?UTF-8?Q?=C3=84pfel?=,tag2` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
|
||||||
|
|
||||||
|
## Markdown formatting
|
||||||
|
_Supported on:_ :material-firefox:
|
||||||
|
|
||||||
|
You can format messages using [Markdown](https://www.markdownguide.org/basic-syntax/) 🤩. That means you can use
|
||||||
|
**bold text**, *italicized text*, links, images, and more. Supported Markdown features (web app only for now):
|
||||||
|
|
||||||
|
- [Emphasis](https://www.markdownguide.org/basic-syntax/#emphasis) such as **bold** (`**bold**`), *italics* (`*italics*`)
|
||||||
|
- [Links](https://www.markdownguide.org/basic-syntax/#links) (`[some tool](https://ntfy.sh)`)
|
||||||
|
- [Images](https://www.markdownguide.org/basic-syntax/#images) (``)
|
||||||
|
- [Code blocks](https://www.markdownguide.org/basic-syntax/#code-blocks) (` ```code blocks``` `) and [inline code](https://www.markdownguide.org/basic-syntax/#inline-code) (`` `inline code` ``)
|
||||||
|
- [Headings](https://www.markdownguide.org/basic-syntax/#headings) (`# headings`, `## headings`, etc.)
|
||||||
|
- [Lists](https://www.markdownguide.org/basic-syntax/#lists) (`- lists`, `1. lists`, etc.)
|
||||||
|
- [Blockquotes](https://www.markdownguide.org/basic-syntax/#blockquotes) (`> blockquotes`)
|
||||||
|
- [Horizontal rules](https://www.markdownguide.org/basic-syntax/#horizontal-rules) (`---`)
|
||||||
|
|
||||||
|
By default, messages sent to ntfy are rendered as plain text. To enable Markdown, set the `X-Markdown` header (or any of
|
||||||
|
its aliases: `Markdown`, or `md`) to `true` (or `1` or `yes`), or set the `Content-Type` header to `text/markdown`.
|
||||||
|
As of today, **Markdown is only supported in the web app.** Here's an example of how to enable Markdown formatting:
|
||||||
|
|
||||||
|
=== "Command line (curl)"
|
||||||
|
```
|
||||||
|
curl \
|
||||||
|
-d "Look ma, **bold text**, *italics*, ..." \
|
||||||
|
-H "Markdown: yes" \
|
||||||
|
ntfy.sh/mytopic
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy CLI"
|
||||||
|
```
|
||||||
|
ntfy publish \
|
||||||
|
mytopic \
|
||||||
|
--markdown \
|
||||||
|
"Look ma, **bold text**, *italics*, ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
POST /mytopic HTTP/1.1
|
||||||
|
Host: ntfy.sh
|
||||||
|
Markdown: yes
|
||||||
|
|
||||||
|
Look ma, **bold text**, *italics*, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
fetch('https://ntfy.sh/mytopic', {
|
||||||
|
method: 'POST', // PUT works too
|
||||||
|
body: 'Look ma, **bold text**, *italics*, ...',
|
||||||
|
headers: { 'Markdown': 'yes' }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Go"
|
||||||
|
``` go
|
||||||
|
http.Post("https://ntfy.sh/mytopic", "text/markdown",
|
||||||
|
strings.NewReader("Look ma, **bold text**, *italics*, ..."))
|
||||||
|
|
||||||
|
// or
|
||||||
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic",
|
||||||
|
strings.NewReader("Look ma, **bold text**, *italics*, ..."))
|
||||||
|
req.Header.Set("Markdown", "yes")
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$Request = @{
|
||||||
|
Method = "POST"
|
||||||
|
URI = "https://ntfy.sh/mytopic"
|
||||||
|
Body = "Look ma, **bold text**, *italics*, ..."
|
||||||
|
Headers = @{
|
||||||
|
Markdown = "yes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Invoke-RestMethod @Request
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
``` python
|
||||||
|
requests.post("https://ntfy.sh/mytopic",
|
||||||
|
data="Look ma, **bold text**, *italics*, ..."
|
||||||
|
headers={ "Markdown": "yes" }))
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PHP"
|
||||||
|
``` php-inline
|
||||||
|
file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST', // PUT also works
|
||||||
|
'header' => 'Content-Type: text/markdown', // !
|
||||||
|
'content' => 'Look ma, **bold text**, *italics*, ...'
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
Here's what that looks like in the web app:
|
||||||
|
|
||||||
|
<figure markdown>
|
||||||
|
{ width=500 }
|
||||||
|
<figcaption>Markdown formatting in the web app</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
## Scheduled delivery
|
## Scheduled delivery
|
||||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
@@ -1004,6 +1107,7 @@ all the supported fields:
|
|||||||
| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
|
| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
|
||||||
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
|
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
|
||||||
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) |
|
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) |
|
||||||
|
| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted |
|
||||||
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
|
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
|
||||||
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
|
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
|
||||||
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
|
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
|
||||||
@@ -3493,6 +3597,7 @@ table in their canonical form.
|
|||||||
| `X-Actions` | `Actions`, `Action` | JSON array or short format of [user actions](#action-buttons) |
|
| `X-Actions` | `Actions`, `Action` | JSON array or short format of [user actions](#action-buttons) |
|
||||||
| `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
|
| `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
|
||||||
| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment |
|
| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment |
|
||||||
|
| `X-Markdown` | `Markdown`, `md` | Enable [Markdown formatting](#markdown-formatting) in the notification body |
|
||||||
| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) |
|
| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) |
|
||||||
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
|
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
|
||||||
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
|
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
|
||||||
@@ -3502,3 +3607,4 @@ table in their canonical form.
|
|||||||
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps |
|
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps |
|
||||||
| `X-Poll-ID` | `Poll-ID` | Internal parameter, used for [iOS push notifications](config.md#ios-instant-notifications) |
|
| `X-Poll-ID` | `Poll-ID` | Internal parameter, used for [iOS push notifications](config.md#ios-instant-notifications) |
|
||||||
| `Authorization` | - | If supported by the server, you can [login to access](#authentication) protected topics |
|
| `Authorization` | - | If supported by the server, you can [login to access](#authentication) protected topics |
|
||||||
|
| `Content-Type` | - | If set to `text/markdown`, [Markdown formatting](#markdown-formatting) is enabled |
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||||
|
|
||||||
### ntfy server v2.6.0
|
## ntfy server v2.6.2
|
||||||
Released June 28, 2023
|
Released June 30, 2023
|
||||||
|
|
||||||
With this release, the ntfy web app now contains a **[progressive web app](https://docs.ntfy.sh/subscribe/pwa/) (PWA)
|
With this release, the ntfy web app now contains a **[progressive web app](subscribe/pwa.md) (PWA)
|
||||||
with Web Push support**, which means you'll be able to **install the ntfy web app on your desktop or phone** similar
|
with Web Push support**, which means you'll be able to **install the ntfy web app on your desktop or phone** similar
|
||||||
to a native app (__even on iOS!__ 🥳). Installing the PWA gives ntfy web its own launcher, a standalone window,
|
to a native app (__even on iOS!__ 🥳). Installing the PWA gives ntfy web its own launcher, a standalone window,
|
||||||
push notifications, and an app badge with the unread notification count.
|
push notifications, and an app badge with the unread notification count. Note that for self-hosted servers,
|
||||||
|
[Web Push](config.md#web-push) must be configured.
|
||||||
|
|
||||||
On top of that, this release also brings **dark mode** 🧛🌙 to the web app.
|
On top of that, this release also brings **dark mode** 🧛🌙 to the web app.
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ if you use promo code `MYTOPIC`). ntfy will always remain open source.
|
|||||||
* Do not forward poll requests for UnifiedPush messages (no ticket, thanks to NoName 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)
|
* Fix `ntfy pub %` segfaulting ([#760](https://github.com/binwiederhier/ntfy/issues/760), thanks to [@clesmian](https://github.com/clesmian) for reporting)
|
||||||
* Newly created access tokens are now lowercase only to fully support `<topic>+<token>@<domain>` email syntax ([#773](https://github.com/binwiederhier/ntfy/issues/773), thanks to gingervitiz for reporting)
|
* Newly created access tokens are now lowercase only to fully support `<topic>+<token>@<domain>` email syntax ([#773](https://github.com/binwiederhier/ntfy/issues/773), thanks to gingervitiz for reporting)
|
||||||
|
* The .1 release fixes a few visual issues with dark mode, and other web app updates ([#791](https://github.com/binwiederhier/ntfy/pull/791), [#793](https://github.com/binwiederhier/ntfy/pull/793), [#792](https://github.com/binwiederhier/ntfy/pull/792), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
||||||
|
* The .2 release fixes issues with the service worker in Firefox and adds automatic service worker updates ([#795](https://github.com/binwiederhier/ntfy/pull/795), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
||||||
|
|
||||||
**Maintenance:**
|
**Maintenance:**
|
||||||
|
|
||||||
@@ -38,6 +41,14 @@ if you use promo code `MYTOPIC`). ntfy will always remain open source.
|
|||||||
* Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), 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))
|
* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
||||||
|
|
||||||
|
**Changes in tarball/zip naming:**
|
||||||
|
Due to a [change in GoReleaser](https://goreleaser.com/deprecations/#archivesreplacements), some of the binary release
|
||||||
|
archives now have slightly different names. My apologies if this causes issues in the downstream projects that use ntfy:
|
||||||
|
|
||||||
|
- `ntfy_v${VERSION}_windows_x86_64.zip` -> `ntfy_v${VERSION}_windows_amd64.zip`
|
||||||
|
- `ntfy_v${VERSION}_linux_x86_64.tar.gz` -> `ntfy_v${VERSION}_linux_amd64.tar.gz`
|
||||||
|
- `ntfy_v${VERSION}_macOS_all.tar.gz` -> `ntfy_v${VERSION}_darwin_all.tar.gz`
|
||||||
|
|
||||||
## ntfy server v2.5.0
|
## ntfy server v2.5.0
|
||||||
Released May 18, 2023
|
Released May 18, 2023
|
||||||
|
|
||||||
@@ -1240,6 +1251,16 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
## Not released yet
|
## Not released yet
|
||||||
|
|
||||||
|
### ntfy server v2.7.0 (UNRELEASED)
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Add support for right-to-left languages (RTL) in the web app ([#663](https://github.com/binwiederhier/ntfy/issues/663), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
||||||
|
|
||||||
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* Fix issues with date/time with different locales ([#700](https://github.com/binwiederhier/ntfy/issues/700), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
||||||
|
|
||||||
### ntfy Android app v1.16.1 (UNRELEASED)
|
### ntfy Android app v1.16.1 (UNRELEASED)
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|||||||
BIN
docs/static/img/web-markdown.png
vendored
Normal file
BIN
docs/static/img/web-markdown.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
@@ -12,6 +12,9 @@ You can get the Android app from both [Google Play](https://play.google.com/stor
|
|||||||
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
||||||
the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||||
|
|
||||||
|
Alternatively, you may also want to consider using the **[progressive web app (PWA)](pwa.md)** instead of the native app.
|
||||||
|
The PWA is a website that you can add to your home screen, and it will behave just like a native app.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
||||||
straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them.
|
straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ Web app installation is **supported on** (see [compatibility table](https://cani
|
|||||||
- **Firefox:** Android, as well as on Windows/Linux [via an extension](https://addons.mozilla.org/en-US/firefox/addon/pwas-for-firefox/)
|
- **Firefox:** Android, as well as on Windows/Linux [via an extension](https://addons.mozilla.org/en-US/firefox/addon/pwas-for-firefox/)
|
||||||
- **Edge:** Windows
|
- **Edge:** Windows
|
||||||
|
|
||||||
|
Note that for self-hosted servers, [Web Push](../config.md#web-push) must be configured for the PWA to work.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Chrome on Desktop
|
### Chrome on Desktop
|
||||||
|
|||||||
14
go.mod
14
go.mod
@@ -4,7 +4,7 @@ go 1.18
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.11.0 // indirect
|
cloud.google.com/go/firestore v1.11.0 // indirect
|
||||||
cloud.google.com/go/storage v1.30.1 // indirect
|
cloud.google.com/go/storage v1.31.0 // indirect
|
||||||
github.com/BurntSushi/toml v1.3.2 // indirect
|
github.com/BurntSushi/toml v1.3.2 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||||
github.com/emersion/go-smtp v0.16.0
|
github.com/emersion/go-smtp v0.16.0
|
||||||
@@ -29,7 +29,7 @@ require (
|
|||||||
firebase.google.com/go/v4 v4.11.0
|
firebase.google.com/go/v4 v4.11.0
|
||||||
github.com/SherClockHolmes/webpush-go v1.2.0
|
github.com/SherClockHolmes/webpush-go v1.2.0
|
||||||
github.com/prometheus/client_golang v1.16.0
|
github.com/prometheus/client_golang v1.16.0
|
||||||
github.com/stripe/stripe-go/v74 v74.23.0
|
github.com/stripe/stripe-go/v74 v74.24.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -64,14 +64,14 @@ require (
|
|||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
golang.org/x/net v0.11.0 // indirect
|
golang.org/x/net v0.11.0 // indirect
|
||||||
golang.org/x/sys v0.9.0 // indirect
|
golang.org/x/sys v0.10.0 // indirect
|
||||||
golang.org/x/text v0.10.0 // indirect
|
golang.org/x/text v0.11.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
google.golang.org/appengine/v2 v2.0.3 // indirect
|
google.golang.org/appengine/v2 v2.0.3 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20230626202813-9b080da550b3 // indirect
|
google.golang.org/genproto v0.0.0-20230629202037-9506855d4529 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20230626202813-9b080da550b3 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230626202813-9b080da550b3 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529 // indirect
|
||||||
google.golang.org/grpc v1.56.1 // indirect
|
google.golang.org/grpc v1.56.1 // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
28
go.sum
28
go.sum
@@ -12,8 +12,8 @@ cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y=
|
|||||||
cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU=
|
cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU=
|
||||||
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
|
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
|
||||||
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
|
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
|
||||||
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
|
cloud.google.com/go/storage v1.31.0 h1:+S3LjjEN2zZ+L5hOwj4+1OkGCsLVe0NzpXKQ1pSdTCI=
|
||||||
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
|
cloud.google.com/go/storage v1.31.0/go.mod h1:81ams1PrhW16L4kF7qg+4mTq7SRs5HsbDTM0bWvrwJ0=
|
||||||
firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk=
|
firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk=
|
||||||
firebase.google.com/go/v4 v4.11.0/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE=
|
firebase.google.com/go/v4 v4.11.0/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE=
|
||||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||||
@@ -143,8 +143,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stripe/stripe-go/v74 v74.23.0 h1:9spORjBMhg8SieRrlrqQdlrw+JllpL6gZnD3QGsCN6Q=
|
github.com/stripe/stripe-go/v74 v74.24.0 h1:h+hXEI5avC5moAh2YLtphMFTBnp11TfXTcP4suuWDLk=
|
||||||
github.com/stripe/stripe-go/v74 v74.23.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
github.com/stripe/stripe-go/v74 v74.24.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||||
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
|
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
|
||||||
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||||
@@ -202,8 +202,8 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
|
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
||||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28=
|
golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28=
|
||||||
@@ -214,8 +214,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
|
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
|
||||||
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -242,12 +242,12 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA
|
|||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto v0.0.0-20230626202813-9b080da550b3 h1:Yofj1/U0xc/Zi5KEpoIxm51I2f85X+eGyY4YzAujRdw=
|
google.golang.org/genproto v0.0.0-20230629202037-9506855d4529 h1:9JucMWR7sPvCxUFd6UsOUNmA5kCcWOfORaT3tpAsKQs=
|
||||||
google.golang.org/genproto v0.0.0-20230626202813-9b080da550b3/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64=
|
google.golang.org/genproto v0.0.0-20230629202037-9506855d4529/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20230626202813-9b080da550b3 h1:wl7z+A0jkB3Rl8Hz74SqGDlnnn5VlL2CV+9UTdZOo00=
|
google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529 h1:s5YSX+ZH5b5vS9rnpGymvIyMpLRJizowqDlOuyjXnTk=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20230626202813-9b080da550b3/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
|
google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230626202813-9b080da550b3 h1:QJuqz7YzNTyKDspkp2lrzqtq4lf2AhUSpXTsGP5SbLw=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529 h1:DEH99RbiLZhMxrpEJCZ0A+wdTe0EOgou/poSLx9vWf4=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230626202813-9b080da550b3/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const (
|
|||||||
attachment_deleted INT NOT NULL,
|
attachment_deleted INT NOT NULL,
|
||||||
sender TEXT NOT NULL,
|
sender TEXT NOT NULL,
|
||||||
user TEXT NOT NULL,
|
user TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
encoding TEXT NOT NULL,
|
encoding TEXT NOT NULL,
|
||||||
published INT NOT NULL
|
published INT NOT NULL
|
||||||
);
|
);
|
||||||
@@ -63,43 +64,43 @@ const (
|
|||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
insertMessageQuery = `
|
insertMessageQuery = `
|
||||||
INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, encoding, published)
|
INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
||||||
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
||||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||||
selectMessagesByIDQuery = `
|
selectMessagesByIDQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE mid = ?
|
WHERE mid = ?
|
||||||
`
|
`
|
||||||
selectMessagesSinceTimeQuery = `
|
selectMessagesSinceTimeQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ? AND published = 1
|
WHERE topic = ? AND time >= ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ?
|
WHERE topic = ? AND time >= ?
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDQuery = `
|
selectMessagesSinceIDQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND id > ? AND published = 1
|
WHERE topic = ? AND id > ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND (id > ? OR published = 0)
|
WHERE topic = ? AND (id > ? OR published = 0)
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesDueQuery = `
|
selectMessagesDueQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE time <= ? AND published = 0
|
WHERE time <= ? AND published = 0
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
@@ -121,7 +122,7 @@ const (
|
|||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
const (
|
const (
|
||||||
currentSchemaVersion = 11
|
currentSchemaVersion = 12
|
||||||
createSchemaVersionTableQuery = `
|
createSchemaVersionTableQuery = `
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
@@ -240,6 +241,11 @@ const (
|
|||||||
);
|
);
|
||||||
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// 11 -> 12
|
||||||
|
migrate11To12AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT('');
|
||||||
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -255,6 +261,7 @@ var (
|
|||||||
8: migrateFrom8,
|
8: migrateFrom8,
|
||||||
9: migrateFrom9,
|
9: migrateFrom9,
|
||||||
10: migrateFrom10,
|
10: migrateFrom10,
|
||||||
|
11: migrateFrom11,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -384,6 +391,7 @@ func (c *messageCache) addMessages(ms []*message) error {
|
|||||||
attachmentDeleted, // Always zero
|
attachmentDeleted, // Always zero
|
||||||
sender,
|
sender,
|
||||||
m.User,
|
m.User,
|
||||||
|
m.ContentType,
|
||||||
m.Encoding,
|
m.Encoding,
|
||||||
published,
|
published,
|
||||||
)
|
)
|
||||||
@@ -656,7 +664,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
func readMessage(rows *sql.Rows) (*message, error) {
|
func readMessage(rows *sql.Rows) (*message, error) {
|
||||||
var timestamp, expires, attachmentSize, attachmentExpires int64
|
var timestamp, expires, attachmentSize, attachmentExpires int64
|
||||||
var priority int
|
var priority int
|
||||||
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, encoding string
|
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&id,
|
&id,
|
||||||
×tamp,
|
×tamp,
|
||||||
@@ -676,6 +684,7 @@ func readMessage(rows *sql.Rows) (*message, error) {
|
|||||||
&attachmentURL,
|
&attachmentURL,
|
||||||
&sender,
|
&sender,
|
||||||
&user,
|
&user,
|
||||||
|
&contentType,
|
||||||
&encoding,
|
&encoding,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -706,22 +715,23 @@ func readMessage(rows *sql.Rows) (*message, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &message{
|
return &message{
|
||||||
ID: id,
|
ID: id,
|
||||||
Time: timestamp,
|
Time: timestamp,
|
||||||
Expires: expires,
|
Expires: expires,
|
||||||
Event: messageEvent,
|
Event: messageEvent,
|
||||||
Topic: topic,
|
Topic: topic,
|
||||||
Message: msg,
|
Message: msg,
|
||||||
Title: title,
|
Title: title,
|
||||||
Priority: priority,
|
Priority: priority,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
Click: click,
|
Click: click,
|
||||||
Icon: icon,
|
Icon: icon,
|
||||||
Actions: actions,
|
Actions: actions,
|
||||||
Attachment: att,
|
Attachment: att,
|
||||||
Sender: senderIP, // Must parse assuming database must be correct
|
Sender: senderIP, // Must parse assuming database must be correct
|
||||||
User: user,
|
User: user,
|
||||||
Encoding: encoding,
|
ContentType: contentType,
|
||||||
|
Encoding: encoding,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,7 +939,7 @@ func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom10(db *sql.DB, cacheDuration time.Duration) error {
|
func migrateFrom10(db *sql.DB, _ time.Duration) error {
|
||||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11")
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11")
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -944,3 +954,19 @@ func migrateFrom10(db *sql.DB, cacheDuration time.Duration) error {
|
|||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateFrom11(db *sql.DB, _ time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 11 to 12")
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(migrate11To12AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(updateSchemaVersion, 12); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1010,6 +1010,10 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
|
||||||
|
if markdown || strings.ToLower(contentType) == "text/markdown" {
|
||||||
|
m.ContentType = "text/markdown"
|
||||||
|
}
|
||||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||||
if unifiedpush {
|
if unifiedpush {
|
||||||
firebase = false
|
firebase = false
|
||||||
@@ -1785,6 +1789,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
|||||||
if m.Icon != "" {
|
if m.Icon != "" {
|
||||||
r.Header.Set("X-Icon", m.Icon)
|
r.Header.Set("X-Icon", m.Icon)
|
||||||
}
|
}
|
||||||
|
if m.Markdown {
|
||||||
|
r.Header.Set("X-Markdown", "yes")
|
||||||
|
}
|
||||||
if len(m.Actions) > 0 {
|
if len(m.Actions) > 0 {
|
||||||
actionsStr, err := json.Marshal(m.Actions)
|
actionsStr, err := json.Marshal(m.Actions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -144,17 +144,18 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
|
|||||||
}
|
}
|
||||||
if allowForward {
|
if allowForward {
|
||||||
data = map[string]string{
|
data = map[string]string{
|
||||||
"id": m.ID,
|
"id": m.ID,
|
||||||
"time": fmt.Sprintf("%d", m.Time),
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
"event": m.Event,
|
"event": m.Event,
|
||||||
"topic": m.Topic,
|
"topic": m.Topic,
|
||||||
"priority": fmt.Sprintf("%d", m.Priority),
|
"priority": fmt.Sprintf("%d", m.Priority),
|
||||||
"tags": strings.Join(m.Tags, ","),
|
"tags": strings.Join(m.Tags, ","),
|
||||||
"click": m.Click,
|
"click": m.Click,
|
||||||
"icon": m.Icon,
|
"icon": m.Icon,
|
||||||
"title": m.Title,
|
"title": m.Title,
|
||||||
"message": m.Message,
|
"message": m.Message,
|
||||||
"encoding": m.Encoding,
|
"content_type": m.ContentType,
|
||||||
|
"encoding": m.Encoding,
|
||||||
}
|
}
|
||||||
if len(m.Actions) > 0 {
|
if len(m.Actions) > 0 {
|
||||||
actions, err := json.Marshal(m.Actions)
|
actions, err := json.Marshal(m.Actions)
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
|||||||
"title": "some title",
|
"title": "some title",
|
||||||
"message": "this is a message",
|
"message": "this is a message",
|
||||||
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
|
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
|
||||||
|
"content_type": "",
|
||||||
"encoding": "",
|
"encoding": "",
|
||||||
"attachment_name": "some file.jpg",
|
"attachment_name": "some file.jpg",
|
||||||
"attachment_type": "image/jpeg",
|
"attachment_type": "image/jpeg",
|
||||||
@@ -203,6 +204,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
|||||||
"title": "some title",
|
"title": "some title",
|
||||||
"message": "this is a message",
|
"message": "this is a message",
|
||||||
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
|
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
|
||||||
|
"content_type": "",
|
||||||
"encoding": "",
|
"encoding": "",
|
||||||
"attachment_name": "some file.jpg",
|
"attachment_name": "some file.jpg",
|
||||||
"attachment_type": "image/jpeg",
|
"attachment_type": "image/jpeg",
|
||||||
|
|||||||
@@ -1518,6 +1518,39 @@ func TestServer_PublishActions_AndPoll(t *testing.T) {
|
|||||||
require.Equal(t, "target_temp_f=65", m.Actions[1].Body)
|
require.Equal(t, "target_temp_f=65", m.Actions[1].Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishMarkdown(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "_underline this_", map[string]string{
|
||||||
|
"Content-Type": "text/markdown",
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "_underline this_", m.Message)
|
||||||
|
require.Equal(t, "text/markdown", m.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishMarkdown_QueryParam(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic?md=1", "_underline this_", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "_underline this_", m.Message)
|
||||||
|
require.Equal(t, "text/markdown", m.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishMarkdown_NotMarkdown(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "_underline this_", map[string]string{
|
||||||
|
"Content-Type": "not-markdown",
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "", m.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishAsJSON(t *testing.T) {
|
func TestServer_PublishAsJSON(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` +
|
body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` +
|
||||||
@@ -1535,12 +1568,25 @@ func TestServer_PublishAsJSON(t *testing.T) {
|
|||||||
require.Equal(t, "google.pdf", m.Attachment.Name)
|
require.Equal(t, "google.pdf", m.Attachment.Name)
|
||||||
require.Equal(t, "http://ntfy.sh", m.Click)
|
require.Equal(t, "http://ntfy.sh", m.Click)
|
||||||
require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon)
|
require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon)
|
||||||
|
require.Equal(t, "", m.ContentType)
|
||||||
|
|
||||||
require.Equal(t, 4, m.Priority)
|
require.Equal(t, 4, m.Priority)
|
||||||
require.True(t, m.Time > time.Now().Unix()+29*60)
|
require.True(t, m.Time > time.Now().Unix()+29*60)
|
||||||
require.True(t, m.Time < time.Now().Unix()+31*60)
|
require.True(t, m.Time < time.Now().Unix()+31*60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAsJSON_Markdown(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
body := `{"topic":"mytopic","message":"**This is bold**","markdown":true}`
|
||||||
|
response := request(t, s, "PUT", "/", body, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
|
require.Equal(t, "**This is bold**", m.Message)
|
||||||
|
require.Equal(t, "text/markdown", m.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishAsJSON_RateLimit_MessageDailyLimit(t *testing.T) {
|
func TestServer_PublishAsJSON_RateLimit_MessageDailyLimit(t *testing.T) {
|
||||||
// Publishing as JSON follows a different path. This ensures that rate
|
// Publishing as JSON follows a different path. This ensures that rate
|
||||||
// limiting works for this endpoint as well
|
// limiting works for this endpoint as well
|
||||||
|
|||||||
@@ -25,23 +25,24 @@ const (
|
|||||||
|
|
||||||
// message represents a message published to a topic
|
// message represents a message published to a topic
|
||||||
type message struct {
|
type message struct {
|
||||||
ID string `json:"id"` // Random message ID
|
ID string `json:"id"` // Random message ID
|
||||||
Time int64 `json:"time"` // Unix time in seconds
|
Time int64 `json:"time"` // Unix time in seconds
|
||||||
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
|
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
|
||||||
Event string `json:"event"` // One of the above
|
Event string `json:"event"` // One of the above
|
||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
Priority int `json:"priority,omitempty"`
|
Priority int `json:"priority,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Click string `json:"click,omitempty"`
|
Click string `json:"click,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
Actions []*action `json:"actions,omitempty"`
|
Actions []*action `json:"actions,omitempty"`
|
||||||
Attachment *attachment `json:"attachment,omitempty"`
|
Attachment *attachment `json:"attachment,omitempty"`
|
||||||
PollID string `json:"poll_id,omitempty"`
|
PollID string `json:"poll_id,omitempty"`
|
||||||
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
|
||||||
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
|
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
||||||
User string `json:"-"` // UserID of the uploader, used to associated attachments
|
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
|
||||||
|
User string `json:"-"` // UserID of the uploader, used to associated attachments
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *message) Context() log.Context {
|
func (m *message) Context() log.Context {
|
||||||
@@ -100,6 +101,7 @@ type publishMessage struct {
|
|||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
Actions []action `json:"actions"`
|
Actions []action `json:"actions"`
|
||||||
Attach string `json:"attach"`
|
Attach string `json:"attach"`
|
||||||
|
Markdown bool `json:"markdown"`
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Call string `json:"call"`
|
Call string `json:"call"`
|
||||||
|
|||||||
889
web/package-lock.json
generated
889
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
|||||||
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/"
|
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emotion/cache": "^11.11.0",
|
||||||
"@emotion/react": "^11.11.0",
|
"@emotion/react": "^11.11.0",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/icons-material": "^5.4.2",
|
"@mui/icons-material": "^5.4.2",
|
||||||
@@ -26,9 +27,12 @@
|
|||||||
"react-dom": "latest",
|
"react-dom": "latest",
|
||||||
"react-i18next": "^11.16.2",
|
"react-i18next": "^11.16.2",
|
||||||
"react-infinite-scroll-component": "^6.1.0",
|
"react-infinite-scroll-component": "^6.1.0",
|
||||||
|
"react-remark": "^2.1.0",
|
||||||
"react-router-dom": "^6.2.2",
|
"react-router-dom": "^6.2.2",
|
||||||
"stacktrace-gps": "^3.0.4",
|
"stacktrace-gps": "^3.0.4",
|
||||||
"stacktrace-js": "^2.0.2"
|
"stacktrace-js": "^2.0.2",
|
||||||
|
"stylis": "^4.3.0",
|
||||||
|
"stylis-plugin-rtl": "^2.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
|||||||
@@ -55,14 +55,14 @@
|
|||||||
"nav_upgrade_banner_label": "Upgrade to ntfy Pro",
|
"nav_upgrade_banner_label": "Upgrade to ntfy Pro",
|
||||||
"nav_upgrade_banner_description": "Reserve topics, more messages & emails, and larger attachments",
|
"nav_upgrade_banner_description": "Reserve topics, more messages & emails, and larger attachments",
|
||||||
"alert_notification_permission_required_title": "Notifications are disabled",
|
"alert_notification_permission_required_title": "Notifications are disabled",
|
||||||
"alert_notification_permission_required_description": "Grant your browser permission to display desktop notifications.",
|
"alert_notification_permission_required_description": "Grant your browser permission to display desktop notifications",
|
||||||
"alert_notification_permission_required_button": "Grant now",
|
"alert_notification_permission_required_button": "Grant now",
|
||||||
"alert_notification_permission_denied_title": "Notifications are blocked",
|
"alert_notification_permission_denied_title": "Notifications are blocked",
|
||||||
"alert_notification_permission_denied_description": "Please re-enable them in your browser and refresh the page to receive notifications",
|
"alert_notification_permission_denied_description": "Please re-enable them in your browser",
|
||||||
"alert_notification_ios_install_required_title": "iOS install required",
|
"alert_notification_ios_install_required_title": "iOS install required",
|
||||||
"alert_notification_ios_install_required_description": "Click on the Share icon and Add to Home Screen to enable notifications on iOS",
|
"alert_notification_ios_install_required_description": "Click on the Share icon and Add to Home Screen to enable notifications on iOS",
|
||||||
"alert_not_supported_title": "Notifications not supported",
|
"alert_not_supported_title": "Notifications not supported",
|
||||||
"alert_not_supported_description": "Notifications are not supported in your browser.",
|
"alert_not_supported_description": "Notifications are not supported in your browser",
|
||||||
"alert_not_supported_context_description": "Notifications are only supported over HTTPS. This is a limitation of the <mdnLink>Notifications API</mdnLink>.",
|
"alert_not_supported_context_description": "Notifications are only supported over HTTPS. This is a limitation of the <mdnLink>Notifications API</mdnLink>.",
|
||||||
"notifications_list": "Notifications list",
|
"notifications_list": "Notifications list",
|
||||||
"notifications_list_item": "Notification",
|
"notifications_list_item": "Notification",
|
||||||
@@ -160,6 +160,7 @@
|
|||||||
"publish_dialog_button_cancel_sending": "Cancel sending",
|
"publish_dialog_button_cancel_sending": "Cancel sending",
|
||||||
"publish_dialog_button_cancel": "Cancel",
|
"publish_dialog_button_cancel": "Cancel",
|
||||||
"publish_dialog_button_send": "Send",
|
"publish_dialog_button_send": "Send",
|
||||||
|
"publish_dialog_checkbox_markdown": "Format as Markdown",
|
||||||
"publish_dialog_checkbox_publish_another": "Publish another",
|
"publish_dialog_checkbox_publish_another": "Publish another",
|
||||||
"publish_dialog_attached_file_title": "Attached file:",
|
"publish_dialog_attached_file_title": "Attached file:",
|
||||||
"publish_dialog_attached_file_filename_placeholder": "Attachment filename",
|
"publish_dialog_attached_file_filename_placeholder": "Attachment filename",
|
||||||
|
|||||||
@@ -352,5 +352,12 @@
|
|||||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} 每年。按月计费。",
|
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} 每年。按月计费。",
|
||||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{价格}} 按年计费。节省 {{save}}。",
|
"account_upgrade_dialog_tier_price_billed_yearly": "{{价格}} 按年计费。节省 {{save}}。",
|
||||||
"account_upgrade_dialog_billing_contact_email": "有关账单问题,请直接<Link>联系我们 </Link>。",
|
"account_upgrade_dialog_billing_contact_email": "有关账单问题,请直接<Link>联系我们 </Link>。",
|
||||||
"account_upgrade_dialog_billing_contact_website": "有关账单问题,请参考我们的<Link>网站 </Link>。"
|
"account_upgrade_dialog_billing_contact_website": "有关账单问题,请参考我们的<Link>网站 </Link>。",
|
||||||
|
"publish_dialog_call_item": "拨打电话 {{number}}",
|
||||||
|
"publish_dialog_call_label": "拨号",
|
||||||
|
"publish_dialog_chip_call_label": "拨号",
|
||||||
|
"publish_dialog_chip_call_no_verified_numbers_tooltip": "未验证的手机号",
|
||||||
|
"account_basics_phone_numbers_title": "电话号码",
|
||||||
|
"account_basics_phone_numbers_description": "电话通知",
|
||||||
|
"account_basics_phone_numbers_dialog_description": "要使用来电通知功能,您需要添加并验证至少一个电话号码。可以通过短信或电话进行验证。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching";
|
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching";
|
||||||
import { NavigationRoute, registerRoute } from "workbox-routing";
|
import { NavigationRoute, registerRoute } from "workbox-routing";
|
||||||
import { NetworkFirst } from "workbox-strategies";
|
import { NetworkFirst } from "workbox-strategies";
|
||||||
|
import { clientsClaim } from "workbox-core";
|
||||||
|
|
||||||
import { dbAsync } from "../src/app/db";
|
import { dbAsync } from "../src/app/db";
|
||||||
|
|
||||||
@@ -224,6 +225,8 @@ precacheAndRoute(
|
|||||||
self.__WB_MANIFEST
|
self.__WB_MANIFEST
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Claim all open windows
|
||||||
|
clientsClaim();
|
||||||
// Delete any cached old dist files from previous service worker versions
|
// Delete any cached old dist files from previous service worker versions
|
||||||
cleanupOutdatedCaches();
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,6 @@ class Notifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async webPushSubscription(hasWebPushTopics) {
|
async webPushSubscription(hasWebPushTopics) {
|
||||||
if (!this.pushPossible()) {
|
|
||||||
throw new Error("Unsupported or denied");
|
|
||||||
}
|
|
||||||
const pushManager = await this.pushManager();
|
const pushManager = await this.pushManager();
|
||||||
const existingSubscription = await pushManager.getSubscription();
|
const existingSubscription = await pushManager.getSubscription();
|
||||||
if (existingSubscription) {
|
if (existingSubscription) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class SubscriptionManager {
|
|||||||
* It is important to note that "mutedUntil" must be part of the where() query, otherwise the Dexie live query
|
* It is important to note that "mutedUntil" must be part of the where() query, otherwise the Dexie live query
|
||||||
* will not react to it, and the Web Push topics will not be updated when the user mutes a topic.
|
* will not react to it, and the Web Push topics will not be updated when the user mutes a topic.
|
||||||
*/
|
*/
|
||||||
async webPushTopics(pushPossible = notifier.pushPossible()) {
|
async webPushTopics(pushPossible) {
|
||||||
if (!pushPossible) {
|
if (!pushPossible) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -120,13 +120,14 @@ class SubscriptionManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateWebPushSubscriptions(presetTopics) {
|
async updateWebPushSubscriptions(topics) {
|
||||||
const topics = presetTopics ?? (await this.webPushTopics());
|
|
||||||
const hasWebPushTopics = topics.length > 0;
|
const hasWebPushTopics = topics.length > 0;
|
||||||
const browserSubscription = await notifier.webPushSubscription(hasWebPushTopics);
|
const browserSubscription = await notifier.webPushSubscription(hasWebPushTopics);
|
||||||
|
|
||||||
if (!browserSubscription) {
|
if (!browserSubscription) {
|
||||||
console.log("[SubscriptionManager] No browser subscription currently exists, so web push was never enabled. Skipping.");
|
console.log(
|
||||||
|
"[SubscriptionManager] No browser subscription currently exists, so web push was never enabled or the notification permission was removed. Skipping."
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,15 +89,15 @@ export const maybeWithAuth = (headers, user) => {
|
|||||||
return headers;
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const maybeAppendActionErrors = (message, notification) => {
|
export const maybeActionErrors = (notification) => {
|
||||||
const actionErrors = (notification.actions ?? [])
|
const actionErrors = (notification.actions ?? [])
|
||||||
.map((action) => action.error)
|
.map((action) => action.error)
|
||||||
.filter((action) => !!action)
|
.filter((action) => !!action)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
if (actionErrors.length === 0) {
|
if (actionErrors.length === 0) {
|
||||||
return message;
|
return undefined;
|
||||||
}
|
}
|
||||||
return `${message}\n\n${actionErrors}`;
|
return actionErrors;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shuffle = (arr) => {
|
export const shuffle = (arr) => {
|
||||||
@@ -130,13 +130,14 @@ export const hashCode = (s) => {
|
|||||||
return hash;
|
return hash;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatShortDateTime = (timestamp) =>
|
export const formatShortDateTime = (timestamp, language) =>
|
||||||
new Intl.DateTimeFormat("default", {
|
new Intl.DateTimeFormat(language, {
|
||||||
dateStyle: "short",
|
dateStyle: "short",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
}).format(new Date(timestamp * 1000));
|
}).format(new Date(timestamp * 1000));
|
||||||
|
|
||||||
export const formatShortDate = (timestamp) => new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000));
|
export const formatShortDate = (timestamp, language) =>
|
||||||
|
new Intl.DateTimeFormat(language, { dateStyle: "short" }).format(new Date(timestamp * 1000));
|
||||||
|
|
||||||
export const formatBytes = (bytes, decimals = 2) => {
|
export const formatBytes = (bytes, decimals = 2) => {
|
||||||
if (bytes === 0) return "0 bytes";
|
if (bytes === 0) return "0 bytes";
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import EditIcon from "@mui/icons-material/Edit";
|
|||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||||
import i18n from "i18next";
|
|
||||||
import humanizeDuration from "humanize-duration";
|
import humanizeDuration from "humanize-duration";
|
||||||
import CelebrationIcon from "@mui/icons-material/Celebration";
|
import CelebrationIcon from "@mui/icons-material/Celebration";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
@@ -224,7 +223,7 @@ const ChangePasswordDialog = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AccountType = () => {
|
const AccountType = () => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { account } = useContext(AccountContext);
|
const { account } = useContext(AccountContext);
|
||||||
const [upgradeDialogKey, setUpgradeDialogKey] = useState(0);
|
const [upgradeDialogKey, setUpgradeDialogKey] = useState(0);
|
||||||
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
||||||
@@ -283,7 +282,7 @@ const AccountType = () => {
|
|||||||
{account.billing?.paid_until && !account.billing?.cancel_at && (
|
{account.billing?.paid_until && !account.billing?.cancel_at && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={t("account_basics_tier_paid_until", {
|
title={t("account_basics_tier_paid_until", {
|
||||||
date: formatShortDate(account.billing?.paid_until),
|
date: formatShortDate(account.billing?.paid_until, i18n.language),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
@@ -328,7 +327,7 @@ const AccountType = () => {
|
|||||||
{account.billing?.cancel_at > 0 && (
|
{account.billing?.cancel_at > 0 && (
|
||||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||||
{t("account_basics_tier_canceled_subscription", {
|
{t("account_basics_tier_canceled_subscription", {
|
||||||
date: formatShortDate(account.billing.cancel_at),
|
date: formatShortDate(account.billing.cancel_at, i18n.language),
|
||||||
})}
|
})}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -556,7 +555,7 @@ const AddPhoneNumberDialog = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Stats = () => {
|
const Stats = () => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { account } = useContext(AccountContext);
|
const { account } = useContext(AccountContext);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -787,7 +786,7 @@ const Tokens = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<div style={{ width: "100%", overflowX: "scroll" }}>{tokens?.length > 0 && <TokensTable tokens={tokens} />}</div>
|
<div style={{ width: "100%", overflowX: "auto" }}>{tokens?.length > 0 && <TokensTable tokens={tokens} />}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardActions>
|
<CardActions>
|
||||||
<Button onClick={handleCreateClick}>{t("account_tokens_table_create_token_button")}</Button>
|
<Button onClick={handleCreateClick}>{t("account_tokens_table_create_token_button")}</Button>
|
||||||
@@ -798,7 +797,7 @@ const Tokens = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TokensTable = (props) => {
|
const TokensTable = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [snackOpen, setSnackOpen] = useState(false);
|
const [snackOpen, setSnackOpen] = useState(false);
|
||||||
const [upsertDialogKey, setUpsertDialogKey] = useState(0);
|
const [upsertDialogKey, setUpsertDialogKey] = useState(0);
|
||||||
const [upsertDialogOpen, setUpsertDialogOpen] = useState(false);
|
const [upsertDialogOpen, setUpsertDialogOpen] = useState(false);
|
||||||
@@ -872,11 +871,11 @@ const TokensTable = (props) => {
|
|||||||
{token.token !== session.token() && (token.label || "-")}
|
{token.token !== session.token() && (token.label || "-")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
|
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
|
||||||
{token.expires ? formatShortDateTime(token.expires) : <em>{t("account_tokens_table_never_expires")}</em>}
|
{token.expires ? formatShortDateTime(token.expires, i18n.language) : <em>{t("account_tokens_table_never_expires")}</em>}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
|
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
|
||||||
<div style={{ display: "flex", alignItems: "center" }}>
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
<span>{formatShortDateTime(token.last_access)}</span>
|
<span>{formatShortDateTime(token.last_access, i18n.language)}</span>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={t("account_tokens_table_last_origin_tooltip", {
|
title={t("account_tokens_table_last_origin_tooltip", {
|
||||||
ip: token.last_origin,
|
ip: token.last_origin,
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ import Navigation from "./Navigation";
|
|||||||
import accountApi from "../app/AccountApi";
|
import accountApi from "../app/AccountApi";
|
||||||
import PopupMenu from "./PopupMenu";
|
import PopupMenu from "./PopupMenu";
|
||||||
import { SubscriptionPopup } from "./SubscriptionPopup";
|
import { SubscriptionPopup } from "./SubscriptionPopup";
|
||||||
|
import { useIsLaunchedPWA } from "./hooks";
|
||||||
|
|
||||||
const ActionBar = (props) => {
|
const ActionBar = (props) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const isLaunchedPWA = useIsLaunchedPWA();
|
||||||
|
|
||||||
let title = "ntfy";
|
let title = "ntfy";
|
||||||
if (props.selected) {
|
if (props.selected) {
|
||||||
title = topicDisplayName(props.selected);
|
title = topicDisplayName(props.selected);
|
||||||
@@ -32,6 +35,22 @@ const ActionBar = (props) => {
|
|||||||
} else if (location.pathname === routes.account) {
|
} else if (location.pathname === routes.account) {
|
||||||
title = t("action_bar_account");
|
title = t("action_bar_account");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getActionBarBackground = () => {
|
||||||
|
if (isLaunchedPWA) {
|
||||||
|
return "#317f6f";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (theme.palette.mode) {
|
||||||
|
case "dark":
|
||||||
|
return "linear-gradient(150deg, #203631 0%, #2a6e60 100%)";
|
||||||
|
|
||||||
|
case "light":
|
||||||
|
default:
|
||||||
|
return "linear-gradient(150deg, #338574 0%, #56bda8 100%)";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar
|
<AppBar
|
||||||
position="fixed"
|
position="fixed"
|
||||||
@@ -44,7 +63,7 @@ const ActionBar = (props) => {
|
|||||||
<Toolbar
|
<Toolbar
|
||||||
sx={{
|
sx={{
|
||||||
pr: "24px",
|
pr: "24px",
|
||||||
background: theme.palette.actionBarBackground,
|
background: getActionBarBackground(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react";
|
import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react";
|
||||||
import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery } from "@mui/material";
|
import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from "@mui/material";
|
||||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
|
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { AllSubscriptions, SingleSubscription } from "./Notifications";
|
import { AllSubscriptions, SingleSubscription } from "./Notifications";
|
||||||
import themeOptions, { darkPalette, lightPalette } from "./theme";
|
import { darkTheme, lightTheme } from "./theme";
|
||||||
import Navigation from "./Navigation";
|
import Navigation from "./Navigation";
|
||||||
import ActionBar from "./ActionBar";
|
import ActionBar from "./ActionBar";
|
||||||
import notifier from "../app/Notifier";
|
|
||||||
import Preferences from "./Preferences";
|
import Preferences from "./Preferences";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import userManager from "../app/UserManager";
|
import userManager from "../app/UserManager";
|
||||||
@@ -23,6 +22,7 @@ import Signup from "./Signup";
|
|||||||
import Account from "./Account";
|
import Account from "./Account";
|
||||||
import "../app/i18n"; // Translations!
|
import "../app/i18n"; // Translations!
|
||||||
import prefs, { THEME } from "../app/Prefs";
|
import prefs, { THEME } from "../app/Prefs";
|
||||||
|
import RTLCacheProvider from "./RTLCacheProvider";
|
||||||
|
|
||||||
export const AccountContext = createContext(null);
|
export const AccountContext = createContext(null);
|
||||||
|
|
||||||
@@ -41,43 +41,47 @@ const darkModeEnabled = (prefersDarkMode, themePreference) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const languageDir = i18n.dir();
|
||||||
|
|
||||||
const [account, setAccount] = useState(null);
|
const [account, setAccount] = useState(null);
|
||||||
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
|
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
|
||||||
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
||||||
const themePreference = useLiveQuery(() => prefs.theme());
|
const themePreference = useLiveQuery(() => prefs.theme());
|
||||||
const theme = React.useMemo(
|
const theme = React.useMemo(
|
||||||
() =>
|
() => createTheme({ ...(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), direction: languageDir }),
|
||||||
createTheme({
|
[prefersDarkMode, themePreference, languageDir]
|
||||||
...themeOptions,
|
|
||||||
palette: {
|
|
||||||
...(darkModeEnabled(prefersDarkMode, themePreference) ? darkPalette : lightPalette),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[prefersDarkMode, themePreference]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute("lang", i18n.language);
|
||||||
|
document.dir = languageDir;
|
||||||
|
}, [i18n.language, languageDir]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Loader />}>
|
<Suspense fallback={<Loader />}>
|
||||||
<BrowserRouter>
|
<RTLCacheProvider>
|
||||||
<ThemeProvider theme={theme}>
|
<BrowserRouter>
|
||||||
<AccountContext.Provider value={accountMemo}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<AccountContext.Provider value={accountMemo}>
|
||||||
<ErrorBoundary>
|
<CssBaseline />
|
||||||
<Routes>
|
<ErrorBoundary>
|
||||||
<Route path={routes.login} element={<Login />} />
|
<Routes>
|
||||||
<Route path={routes.signup} element={<Signup />} />
|
<Route path={routes.login} element={<Login />} />
|
||||||
<Route element={<Layout />}>
|
<Route path={routes.signup} element={<Signup />} />
|
||||||
<Route path={routes.app} element={<AllSubscriptions />} />
|
<Route element={<Layout />}>
|
||||||
<Route path={routes.account} element={<Account />} />
|
<Route path={routes.app} element={<AllSubscriptions />} />
|
||||||
<Route path={routes.settings} element={<Preferences />} />
|
<Route path={routes.account} element={<Account />} />
|
||||||
<Route path={routes.subscription} element={<SingleSubscription />} />
|
<Route path={routes.settings} element={<Preferences />} />
|
||||||
<Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
|
<Route path={routes.subscription} element={<SingleSubscription />} />
|
||||||
</Route>
|
<Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
|
||||||
</Routes>
|
</Route>
|
||||||
</ErrorBoundary>
|
</Routes>
|
||||||
</AccountContext.Provider>
|
</ErrorBoundary>
|
||||||
</ThemeProvider>
|
</AccountContext.Provider>
|
||||||
</BrowserRouter>
|
</ThemeProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</RTLCacheProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -91,7 +95,6 @@ const Layout = () => {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { account, setAccount } = useContext(AccountContext);
|
const { account, setAccount } = useContext(AccountContext);
|
||||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||||
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
|
|
||||||
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
||||||
const users = useLiveQuery(() => userManager.all());
|
const users = useLiveQuery(() => userManager.all());
|
||||||
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
||||||
@@ -115,10 +118,8 @@ const Layout = () => {
|
|||||||
<Navigation
|
<Navigation
|
||||||
subscriptions={subscriptionsWithoutInternal}
|
subscriptions={subscriptionsWithoutInternal}
|
||||||
selectedSubscription={selected}
|
selectedSubscription={selected}
|
||||||
notificationsGranted={notificationsGranted}
|
|
||||||
mobileDrawerOpen={mobileDrawerOpen}
|
mobileDrawerOpen={mobileDrawerOpen}
|
||||||
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
||||||
onNotificationGranted={setNotificationsGranted}
|
|
||||||
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
|
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
|
||||||
/>
|
/>
|
||||||
<Main>
|
<Main>
|
||||||
@@ -143,7 +144,7 @@ const Main = (props) => (
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
padding: 3,
|
padding: { xs: 0, md: 3 },
|
||||||
width: { sm: `calc(100% - ${Navigation.width}px)` },
|
width: { sm: `calc(100% - ${Navigation.width}px)` },
|
||||||
height: "100dvh",
|
height: "100dvh",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
IconButton,
|
IconButton,
|
||||||
Button,
|
Button,
|
||||||
|
useTheme,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
@@ -43,6 +44,7 @@ import UpgradeDialog from "./UpgradeDialog";
|
|||||||
import { AccountContext } from "./App";
|
import { AccountContext } from "./App";
|
||||||
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
|
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
|
||||||
import { SubscriptionPopup } from "./SubscriptionPopup";
|
import { SubscriptionPopup } from "./SubscriptionPopup";
|
||||||
|
import { useNotificationPermissionListener } from "./hooks";
|
||||||
|
|
||||||
const navWidth = 280;
|
const navWidth = 280;
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ const Navigation = (props) => {
|
|||||||
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
|
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
|
||||||
sx={{
|
sx={{
|
||||||
display: { xs: "block", sm: "none" },
|
display: { xs: "block", sm: "none" },
|
||||||
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
|
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth, backgroundImage: "none" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{navigationList}
|
{navigationList}
|
||||||
@@ -82,6 +84,7 @@ const Navigation = (props) => {
|
|||||||
Navigation.width = navWidth;
|
Navigation.width = navWidth;
|
||||||
|
|
||||||
const NavList = (props) => {
|
const NavList = (props) => {
|
||||||
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -109,17 +112,12 @@ const NavList = (props) => {
|
|||||||
const isPaid = account?.billing?.subscription;
|
const isPaid = account?.billing?.subscription;
|
||||||
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
|
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
|
||||||
const showSubscriptionsList = props.subscriptions?.length > 0;
|
const showSubscriptionsList = props.subscriptions?.length > 0;
|
||||||
const [showNotificationPermissionRequired, setShowNotificationPermissionRequired] = useState(notifier.notRequested());
|
const showNotificationPermissionRequired = useNotificationPermissionListener(() => notifier.notRequested());
|
||||||
const [showNotificationPermissionDenied, setShowNotificationPermissionDenied] = useState(notifier.denied());
|
const showNotificationPermissionDenied = useNotificationPermissionListener(() => notifier.denied());
|
||||||
const showNotificationIOSInstallRequired = notifier.iosSupportedButInstallRequired();
|
const showNotificationIOSInstallRequired = notifier.iosSupportedButInstallRequired();
|
||||||
const showNotificationBrowserNotSupportedBox = !showNotificationIOSInstallRequired && !notifier.browserSupported();
|
const showNotificationBrowserNotSupportedBox = !showNotificationIOSInstallRequired && !notifier.browserSupported();
|
||||||
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
|
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
|
||||||
|
|
||||||
const refreshPermissions = () => {
|
|
||||||
setShowNotificationPermissionRequired(notifier.notRequested());
|
|
||||||
setShowNotificationPermissionDenied(notifier.denied());
|
|
||||||
};
|
|
||||||
|
|
||||||
const alertVisible =
|
const alertVisible =
|
||||||
showNotificationPermissionRequired ||
|
showNotificationPermissionRequired ||
|
||||||
showNotificationPermissionDenied ||
|
showNotificationPermissionDenied ||
|
||||||
@@ -130,8 +128,8 @@ const NavList = (props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
|
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
|
||||||
<List component="nav" sx={{ paddingTop: alertVisible ? "0" : "" }}>
|
<List component="nav" sx={{ paddingTop: { xs: 0, sm: alertVisible ? 0 : "" } }}>
|
||||||
{showNotificationPermissionRequired && <NotificationPermissionRequired refreshPermissions={refreshPermissions} />}
|
{showNotificationPermissionRequired && <NotificationPermissionRequired />}
|
||||||
{showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}
|
{showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}
|
||||||
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
|
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
|
||||||
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert />}
|
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert />}
|
||||||
@@ -190,7 +188,11 @@ const NavList = (props) => {
|
|||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_subscribe")} />
|
<ListItemText primary={t("nav_button_subscribe")} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
{showUpgradeBanner && <UpgradeBanner />}
|
{showUpgradeBanner && (
|
||||||
|
// The text background gradient didn't seem to do well with switching between light/dark mode,
|
||||||
|
// So adding a `key` forces React to replace the entire component when the theme changes
|
||||||
|
<UpgradeBanner key={`upgrade-banner-${theme.palette.mode}`} mode={theme.palette.mode} />
|
||||||
|
)}
|
||||||
</List>
|
</List>
|
||||||
<SubscribeDialog
|
<SubscribeDialog
|
||||||
key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed
|
key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed
|
||||||
@@ -203,7 +205,7 @@ const NavList = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const UpgradeBanner = () => {
|
const UpgradeBanner = ({ mode }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [dialogKey, setDialogKey] = useState(0);
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
@@ -220,13 +222,16 @@ const UpgradeBanner = () => {
|
|||||||
width: `${Navigation.width - 1}px`,
|
width: `${Navigation.width - 1}px`,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
mt: "auto",
|
mt: "auto",
|
||||||
background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
|
background:
|
||||||
|
mode === "light"
|
||||||
|
? "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)"
|
||||||
|
: "linear-gradient(150deg, #203631 0%, #2a6e60 100%)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Divider />
|
<Divider />
|
||||||
<ListItemButton onClick={handleClick} sx={{ pt: 2, pb: 2 }}>
|
<ListItemButton onClick={handleClick} sx={{ pt: 2, pb: 2 }}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large" />
|
<CelebrationIcon sx={{ color: mode === "light" ? "#55b86e" : "#00ff95" }} fontSize="large" />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
sx={{ ml: 1 }}
|
sx={{ ml: 1 }}
|
||||||
@@ -236,7 +241,10 @@ const UpgradeBanner = () => {
|
|||||||
style: {
|
style: {
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: "1.1rem",
|
fontSize: "1.1rem",
|
||||||
background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
|
background:
|
||||||
|
mode === "light"
|
||||||
|
? "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)"
|
||||||
|
: "-webkit-linear-gradient(45deg,rgb(255, 255, 255), #00ff95 80%)",
|
||||||
WebkitBackgroundClip: "text",
|
WebkitBackgroundClip: "text",
|
||||||
WebkitTextFillColor: "transparent",
|
WebkitTextFillColor: "transparent",
|
||||||
},
|
},
|
||||||
@@ -354,11 +362,10 @@ const SubscriptionItem = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const NotificationPermissionRequired = ({ refreshPermissions }) => {
|
const NotificationPermissionRequired = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const requestPermission = async () => {
|
const requestPermission = async () => {
|
||||||
await notifier.maybeRequestPermission();
|
await notifier.maybeRequestPermission();
|
||||||
refreshPermissions();
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Alert severity="warning" sx={{ paddingTop: 2 }}>
|
<Alert severity="warning" sx={{ paddingTop: 2 }}>
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ import { useLiveQuery } from "dexie-react-hooks";
|
|||||||
import InfiniteScroll from "react-infinite-scroll-component";
|
import InfiniteScroll from "react-infinite-scroll-component";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useOutletContext } from "react-router-dom";
|
import { useOutletContext } from "react-router-dom";
|
||||||
import { formatBytes, formatShortDateTime, maybeAppendActionErrors, openUrl, shortUrl, topicShortUrl, unmatchedTags } from "../app/utils";
|
import { useRemark } from "react-remark";
|
||||||
|
import styled from "@emotion/styled";
|
||||||
|
import { formatBytes, formatShortDateTime, maybeActionErrors, openUrl, shortUrl, topicShortUrl, unmatchedTags } from "../app/utils";
|
||||||
import { formatMessage, formatTitle } from "../app/notificationUtils";
|
import { formatMessage, formatTitle } from "../app/notificationUtils";
|
||||||
import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
|
import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
@@ -159,11 +161,72 @@ const autolink = (s) => {
|
|||||||
return <>{parts}</>;
|
return <>{parts}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MarkdownContainer = styled("div")`
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
p,
|
||||||
|
pre,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote,
|
||||||
|
pre {
|
||||||
|
border-radius: 3px;
|
||||||
|
background: ${(props) => (props.theme.palette.mode === "light" ? "#f5f5f5" : "#333")};
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote {
|
||||||
|
padding-inline: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MarkdownContent = ({ content }) => {
|
||||||
|
const [reactContent, setMarkdownSource] = useRemark();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMarkdownSource(content);
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
return <MarkdownContainer>{reactContent}</MarkdownContainer>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotificationBody = ({ notification }) => {
|
||||||
|
const displayAsMarkdown = notification.content_type === "text/markdown";
|
||||||
|
const formatted = formatMessage(notification);
|
||||||
|
if (displayAsMarkdown) {
|
||||||
|
return <MarkdownContent content={formatted} />;
|
||||||
|
}
|
||||||
|
return autolink(formatted);
|
||||||
|
};
|
||||||
|
|
||||||
const NotificationItem = (props) => {
|
const NotificationItem = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { notification } = props;
|
const { notification } = props;
|
||||||
const { attachment } = notification;
|
const { attachment } = notification;
|
||||||
const date = formatShortDateTime(notification.time);
|
const date = formatShortDateTime(notification.time, i18n.language);
|
||||||
const otherTags = unmatchedTags(notification.tags);
|
const otherTags = unmatchedTags(notification.tags);
|
||||||
const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
|
const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
@@ -183,8 +246,9 @@ const NotificationItem = (props) => {
|
|||||||
const hasClickAction = notification.click;
|
const hasClickAction = notification.click;
|
||||||
const hasUserActions = notification.actions && notification.actions.length > 0;
|
const hasUserActions = notification.actions && notification.actions.length > 0;
|
||||||
const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
|
const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
|
<Card sx={{ padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tooltip title={t("notifications_delete")} enterDelay={500}>
|
<Tooltip title={t("notifications_delete")} enterDelay={500}>
|
||||||
<IconButton onClick={handleDelete} sx={{ float: "right", marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}>
|
<IconButton onClick={handleDelete} sx={{ float: "right", marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}>
|
||||||
@@ -230,7 +294,8 @@ const NotificationItem = (props) => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
|
<Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
|
||||||
{autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
|
<NotificationBody notification={notification} />
|
||||||
|
{maybeActionErrors(notification)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{attachment && <Attachment attachment={attachment} />}
|
{attachment && <Attachment attachment={attachment} />}
|
||||||
{tags && (
|
{tags && (
|
||||||
@@ -277,7 +342,7 @@ const NotificationItem = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Attachment = (props) => {
|
const Attachment = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { attachment } = props;
|
const { attachment } = props;
|
||||||
const expired = attachment.expires && attachment.expires < Date.now() / 1000;
|
const expired = attachment.expires && attachment.expires < Date.now() / 1000;
|
||||||
const expires = attachment.expires && attachment.expires > Date.now() / 1000;
|
const expires = attachment.expires && attachment.expires > Date.now() / 1000;
|
||||||
@@ -296,7 +361,7 @@ const Attachment = (props) => {
|
|||||||
if (expires) {
|
if (expires) {
|
||||||
infos.push(
|
infos.push(
|
||||||
t("notifications_attachment_link_expires", {
|
t("notifications_attachment_link_expires", {
|
||||||
date: formatShortDateTime(attachment.expires),
|
date: formatShortDateTime(attachment.expires, i18n.language),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ const PrefRow = styled("div")`
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
||||||
> :first-child {
|
> div:first-of-type {
|
||||||
flex: 1 0 40%;
|
flex: 1 0 40%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: ${(props) => (props.alignTop ? "normal" : "center")};
|
justify-content: ${(props) => (props.alignTop ? "normal" : "center")};
|
||||||
}
|
}
|
||||||
|
|
||||||
> :last-child {
|
> div:last-of-type {
|
||||||
flex: 1 0 calc(60% - 50px);
|
flex: 1 0 calc(60% - 50px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -29,12 +29,12 @@ const PrefRow = styled("div")`
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
> :first-child,
|
> :div:first-of-type,
|
||||||
> :last-child {
|
> :div:last-of-type {
|
||||||
flex: unset;
|
flex: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
> :last-child {
|
> div:last-of-type {
|
||||||
.MuiFormControl-root {
|
.MuiFormControl-root {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./Rese
|
|||||||
import { UnauthorizedError } from "../app/errors";
|
import { UnauthorizedError } from "../app/errors";
|
||||||
import { subscribeTopic } from "./SubscribeDialog";
|
import { subscribeTopic } from "./SubscribeDialog";
|
||||||
import notifier from "../app/Notifier";
|
import notifier from "../app/Notifier";
|
||||||
import { useIsLaunchedPWA } from "./hooks";
|
import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks";
|
||||||
|
|
||||||
const maybeUpdateAccountSettings = async (payload) => {
|
const maybeUpdateAccountSettings = async (payload) => {
|
||||||
if (!session.exists()) {
|
if (!session.exists()) {
|
||||||
@@ -79,6 +79,7 @@ const Preferences = () => (
|
|||||||
const Notifications = () => {
|
const Notifications = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isLaunchedPWA = useIsLaunchedPWA();
|
const isLaunchedPWA = useIsLaunchedPWA();
|
||||||
|
const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{ p: 3 }} aria-label={t("prefs_notifications_title")}>
|
<Card sx={{ p: 3 }} aria-label={t("prefs_notifications_title")}>
|
||||||
@@ -89,7 +90,7 @@ const Notifications = () => {
|
|||||||
<Sound />
|
<Sound />
|
||||||
<MinPriority />
|
<MinPriority />
|
||||||
<DeleteAfter />
|
<DeleteAfter />
|
||||||
{!isLaunchedPWA && notifier.pushPossible() && <WebPushEnabled />}
|
{!isLaunchedPWA && pushPossible && <WebPushEnabled />}
|
||||||
</PrefGroup>
|
</PrefGroup>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -240,7 +241,7 @@ const DeleteAfter = () => {
|
|||||||
const Theme = () => {
|
const Theme = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const labelId = "prefTheme";
|
const labelId = "prefTheme";
|
||||||
const enabled = useLiveQuery(async () => prefs.theme());
|
const theme = useLiveQuery(async () => prefs.theme());
|
||||||
const handleChange = async (ev) => {
|
const handleChange = async (ev) => {
|
||||||
await prefs.setTheme(ev.target.value);
|
await prefs.setTheme(ev.target.value);
|
||||||
};
|
};
|
||||||
@@ -248,7 +249,7 @@ const Theme = () => {
|
|||||||
return (
|
return (
|
||||||
<Pref labelId={labelId} title={t("prefs_appearance_theme_title")}>
|
<Pref labelId={labelId} title={t("prefs_appearance_theme_title")}>
|
||||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||||
<Select value={enabled ?? false} onChange={handleChange} aria-labelledby={labelId}>
|
<Select value={theme ?? THEME.SYSTEM} onChange={handleChange} aria-labelledby={labelId}>
|
||||||
<MenuItem value={THEME.SYSTEM}>{t("prefs_appearance_theme_system")}</MenuItem>
|
<MenuItem value={THEME.SYSTEM}>{t("prefs_appearance_theme_system")}</MenuItem>
|
||||||
<MenuItem value={THEME.DARK}>{t("prefs_appearance_theme_dark")}</MenuItem>
|
<MenuItem value={THEME.DARK}>{t("prefs_appearance_theme_dark")}</MenuItem>
|
||||||
<MenuItem value={THEME.LIGHT}>{t("prefs_appearance_theme_light")}</MenuItem>
|
<MenuItem value={THEME.LIGHT}>{t("prefs_appearance_theme_light")}</MenuItem>
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const PublishDialog = (props) => {
|
|||||||
const [call, setCall] = useState("");
|
const [call, setCall] = useState("");
|
||||||
const [delay, setDelay] = useState("");
|
const [delay, setDelay] = useState("");
|
||||||
const [publishAnother, setPublishAnother] = useState(false);
|
const [publishAnother, setPublishAnother] = useState(false);
|
||||||
|
const [markdownEnabled, setMarkdownEnabled] = useState(false);
|
||||||
|
|
||||||
const [showTopicUrl, setShowTopicUrl] = useState("");
|
const [showTopicUrl, setShowTopicUrl] = useState("");
|
||||||
const [showClickUrl, setShowClickUrl] = useState(false);
|
const [showClickUrl, setShowClickUrl] = useState(false);
|
||||||
@@ -148,6 +149,10 @@ const PublishDialog = (props) => {
|
|||||||
if (attachFile && message.trim()) {
|
if (attachFile && message.trim()) {
|
||||||
url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
|
url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
|
||||||
}
|
}
|
||||||
|
if (markdownEnabled) {
|
||||||
|
url.searchParams.append("markdown", "true");
|
||||||
|
}
|
||||||
|
|
||||||
const body = attachFile || message;
|
const body = attachFile || message;
|
||||||
try {
|
try {
|
||||||
const user = await userManager.get(baseUrl);
|
const user = await userManager.get(baseUrl);
|
||||||
@@ -353,6 +358,20 @@ const PublishDialog = (props) => {
|
|||||||
"aria-label": t("publish_dialog_message_label"),
|
"aria-label": t("publish_dialog_message_label"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
label={t("publish_dialog_checkbox_markdown")}
|
||||||
|
sx={{ marginRight: 2 }}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={markdownEnabled}
|
||||||
|
onChange={(ev) => setMarkdownEnabled(ev.target.checked)}
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("publish_dialog_checkbox_markdown"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<div style={{ display: "flex" }}>
|
<div style={{ display: "flex" }}>
|
||||||
<EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} />
|
<EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} />
|
||||||
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
|
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
|
||||||
|
|||||||
22
web/src/components/RTLCacheProvider.jsx
Normal file
22
web/src/components/RTLCacheProvider.jsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import rtlPlugin from "stylis-plugin-rtl";
|
||||||
|
import { CacheProvider } from "@emotion/react";
|
||||||
|
import createCache from "@emotion/cache";
|
||||||
|
import { prefixer } from "stylis";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// https://mui.com/material-ui/guides/right-to-left
|
||||||
|
|
||||||
|
const cacheRtl = createCache({
|
||||||
|
key: "muirtl",
|
||||||
|
stylisPlugins: [prefixer, rtlPlugin],
|
||||||
|
});
|
||||||
|
|
||||||
|
const RTLCacheProvider = ({ children }) => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
|
return i18n.dir() === "rtl" ? <CacheProvider value={cacheRtl}>{children}</CacheProvider> : children;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RTLCacheProvider;
|
||||||
@@ -117,10 +117,16 @@ export const SubscriptionPopup = (props) => {
|
|||||||
])[0];
|
])[0];
|
||||||
const nowSeconds = Math.round(Date.now() / 1000);
|
const nowSeconds = Math.round(Date.now() / 1000);
|
||||||
const message = shuffle([
|
const message = shuffle([
|
||||||
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
|
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(
|
||||||
|
nowSeconds,
|
||||||
|
"en-US"
|
||||||
|
)} right now. Is that early or late?`,
|
||||||
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
|
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
|
||||||
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
|
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
|
||||||
`Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
|
`Alright then, it's ${formatShortDateTime(
|
||||||
|
nowSeconds,
|
||||||
|
"en-US"
|
||||||
|
)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
|
||||||
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
|
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
|
||||||
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
|
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
|
||||||
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`,
|
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const Banner = {
|
|||||||
|
|
||||||
const UpgradeDialog = (props) => {
|
const UpgradeDialog = (props) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { account } = useContext(AccountContext); // May be undefined!
|
const { account } = useContext(AccountContext); // May be undefined!
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [tiers, setTiers] = useState(null);
|
const [tiers, setTiers] = useState(null);
|
||||||
@@ -233,7 +233,7 @@ const UpgradeDialog = (props) => {
|
|||||||
<Trans
|
<Trans
|
||||||
i18nKey="account_upgrade_dialog_cancel_warning"
|
i18nKey="account_upgrade_dialog_cancel_warning"
|
||||||
values={{
|
values={{
|
||||||
date: formatShortDate(account?.billing?.paid_until || 0),
|
date: formatShortDate(account?.billing?.paid_until || 0, i18n.language),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -136,8 +136,31 @@ export const useAutoSubscribe = (subscriptions, selected) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const webPushBroadcastChannel = new BroadcastChannel("web-push-broadcast");
|
const webPushBroadcastChannel = new BroadcastChannel("web-push-broadcast");
|
||||||
const matchMedia = window.matchMedia("(display-mode: standalone)");
|
|
||||||
const isIOSStandalone = window.navigator.standalone === true;
|
/**
|
||||||
|
* Hook to return a value that's refreshed when the notification permission changes
|
||||||
|
*/
|
||||||
|
export const useNotificationPermissionListener = (query) => {
|
||||||
|
const [result, setResult] = useState(query());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
setResult(query());
|
||||||
|
};
|
||||||
|
|
||||||
|
if ("permissions" in navigator) {
|
||||||
|
navigator.permissions.query({ name: "notifications" }).then((permission) => {
|
||||||
|
permission.addEventListener("change", handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
permission.removeEventListener("change", handler);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the Web Push subscriptions when the list of topics changes,
|
* Updates the Web Push subscriptions when the list of topics changes,
|
||||||
@@ -145,11 +168,12 @@ const isIOSStandalone = window.navigator.standalone === true;
|
|||||||
* the service worker, since the service worker cannot play sounds.
|
* the service worker, since the service worker cannot play sounds.
|
||||||
*/
|
*/
|
||||||
const useWebPushListener = (topics) => {
|
const useWebPushListener = (topics) => {
|
||||||
const [lastTopics, setLastTopics] = useState();
|
const [prevUpdate, setPrevUpdate] = useState();
|
||||||
|
const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const topicsChanged = JSON.stringify(topics) !== JSON.stringify(lastTopics);
|
const nextUpdate = JSON.stringify({ topics, pushPossible });
|
||||||
if (!notifier.pushPossible() || !topicsChanged) {
|
if (topics === undefined || nextUpdate === prevUpdate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,12 +181,12 @@ const useWebPushListener = (topics) => {
|
|||||||
try {
|
try {
|
||||||
console.log("[useWebPushListener] Refreshing web push subscriptions", topics);
|
console.log("[useWebPushListener] Refreshing web push subscriptions", topics);
|
||||||
await subscriptionManager.updateWebPushSubscriptions(topics);
|
await subscriptionManager.updateWebPushSubscriptions(topics);
|
||||||
setLastTopics(topics);
|
setPrevUpdate(nextUpdate);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[useWebPushListener] Error refreshing web push subscriptions", e);
|
console.error("[useWebPushListener] Error refreshing web push subscriptions", e);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [topics, lastTopics]);
|
}, [topics, pushPossible, prevUpdate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMessage = () => {
|
const onMessage = () => {
|
||||||
@@ -183,25 +207,7 @@ const useWebPushListener = (topics) => {
|
|||||||
* automatically.
|
* automatically.
|
||||||
*/
|
*/
|
||||||
export const useWebPushTopics = () => {
|
export const useWebPushTopics = () => {
|
||||||
const [pushPossible, setPushPossible] = useState(notifier.pushPossible());
|
const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible());
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = () => {
|
|
||||||
const newPushPossible = notifier.pushPossible();
|
|
||||||
console.log(`[useWebPushTopics] Notification Permission changed`, { pushPossible: newPushPossible });
|
|
||||||
setPushPossible(newPushPossible);
|
|
||||||
};
|
|
||||||
|
|
||||||
if ("permissions" in navigator) {
|
|
||||||
navigator.permissions.query({ name: "notifications" }).then((permission) => {
|
|
||||||
permission.addEventListener("change", handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
permission.removeEventListener("change", handler);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const topics = useLiveQuery(
|
const topics = useLiveQuery(
|
||||||
async () => subscriptionManager.webPushTopics(pushPossible),
|
async () => subscriptionManager.webPushTopics(pushPossible),
|
||||||
@@ -214,6 +220,9 @@ export const useWebPushTopics = () => {
|
|||||||
return topics;
|
return topics;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const matchMedia = window.matchMedia("(display-mode: standalone)");
|
||||||
|
const isIOSStandalone = window.navigator.standalone === true;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Watches the "display-mode" to detect if the app is running as a standalone app (PWA).
|
* Watches the "display-mode" to detect if the app is running as a standalone app (PWA).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** @type {import("@mui/material").ThemeOptions} */
|
/** @type {import("@mui/material").ThemeOptions} */
|
||||||
const themeOptions = {
|
const baseThemeOptions = {
|
||||||
components: {
|
components: {
|
||||||
MuiListItemIcon: {
|
MuiListItemIcon: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
@@ -22,37 +22,53 @@ const themeOptions = {
|
|||||||
|
|
||||||
// https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/colors.xml
|
// https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/colors.xml
|
||||||
|
|
||||||
/** @type {import("@mui/material").ThemeOptions['palette']} */
|
/** @type {import("@mui/material").ThemeOptions} */
|
||||||
export const lightPalette = {
|
export const lightTheme = {
|
||||||
mode: "light",
|
...baseThemeOptions,
|
||||||
primary: {
|
components: {
|
||||||
main: "#338574",
|
...baseThemeOptions.components,
|
||||||
},
|
},
|
||||||
secondary: {
|
palette: {
|
||||||
main: "#6cead0",
|
mode: "light",
|
||||||
|
primary: {
|
||||||
|
main: "#338574",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: "#6cead0",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
main: "#c30000",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
error: {
|
|
||||||
main: "#c30000",
|
|
||||||
},
|
|
||||||
actionBarBackground: "linear-gradient(150deg, #338574 0%, #56bda8 100%)",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @type {import("@mui/material").ThemeOptions['palette']} */
|
/** @type {import("@mui/material").ThemeOptions} */
|
||||||
export const darkPalette = {
|
export const darkTheme = {
|
||||||
mode: "dark",
|
...baseThemeOptions,
|
||||||
background: {
|
components: {
|
||||||
paper: "#1b2124",
|
...baseThemeOptions.components,
|
||||||
|
MuiSnackbarContent: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
color: "#000",
|
||||||
|
backgroundColor: "#aeaeae",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
primary: {
|
palette: {
|
||||||
main: "#65b5a3",
|
mode: "dark",
|
||||||
|
background: {
|
||||||
|
paper: "#1b2124",
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
main: "#65b5a3",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: "#6cead0",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
main: "#fe4d2e",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
secondary: {
|
|
||||||
main: "#6cead0",
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
main: "#fe4d2e",
|
|
||||||
},
|
|
||||||
actionBarBackground: "linear-gradient(150deg, #203631 0%, #2a6e60 100%)",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default themeOptions;
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./components/App";
|
import App from "./components/App";
|
||||||
|
import registerSW from "./registerSW";
|
||||||
|
|
||||||
|
registerSW();
|
||||||
|
|
||||||
const root = createRoot(document.querySelector("#root"));
|
const root = createRoot(document.querySelector("#root"));
|
||||||
root.render(<App />);
|
root.render(<App />);
|
||||||
|
|||||||
31
web/src/registerSW.js
Normal file
31
web/src/registerSW.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// eslint-disable-next-line import/no-unresolved
|
||||||
|
import { registerSW as viteRegisterSW } from "virtual:pwa-register";
|
||||||
|
|
||||||
|
// fetch new sw every hour, i.e. update app every hour while running
|
||||||
|
const intervalMS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html
|
||||||
|
const registerSW = () =>
|
||||||
|
viteRegisterSW({
|
||||||
|
onRegisteredSW(swUrl, registration) {
|
||||||
|
if (!registration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
if (registration.installing || navigator?.onLine === false) return;
|
||||||
|
|
||||||
|
const resp = await fetch(swUrl, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
cache: "no-store",
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp?.status === 200) await registration.update();
|
||||||
|
}, intervalMS);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default registerSW;
|
||||||
@@ -16,7 +16,8 @@ export default defineConfig(({ mode }) => ({
|
|||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: "autoUpdate",
|
registerType: "autoUpdate",
|
||||||
injectRegister: "inline",
|
// see registerSW.js imported by index.jsx
|
||||||
|
injectRegister: null,
|
||||||
strategies: "injectManifest",
|
strategies: "injectManifest",
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -25,7 +26,7 @@ export default defineConfig(({ mode }) => ({
|
|||||||
navigateFallback: "index.html",
|
navigateFallback: "index.html",
|
||||||
},
|
},
|
||||||
injectManifest: {
|
injectManifest: {
|
||||||
globPatterns: ["**/*.{js,css,html,mp3,ico,png,svg,json}"],
|
globPatterns: ["**/*.{js,css,html,ico,png,svg,json}"],
|
||||||
globIgnores: ["config.js"],
|
globIgnores: ["config.js"],
|
||||||
manifestTransforms: [
|
manifestTransforms: [
|
||||||
(entries) => ({
|
(entries) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user