Compare commits

...

50 Commits

Author SHA1 Message Date
Philipp C. Heckel
872bc6d307 Merge pull request #810 from nihalgonsalves/ng/markdown
Web app: add markdown publish
2023-07-09 07:35:38 -04:00
Nihal Gonsalves
c8f880c701 Web app: add a “publish as markdown” option 2023-07-09 10:28:07 +02:00
binwiederhier
f2d3f0bdf9 Remove underlines 2023-07-08 22:28:41 -04:00
binwiederhier
9f8c63c7d5 Docs etc 2023-07-08 21:54:54 -04:00
binwiederhier
2b5a1a7a1c Documentation 2023-07-08 21:45:03 -04:00
binwiederhier
499b2fb0d6 Docs, tests 2023-07-08 15:48:08 -04:00
binwiederhier
b7679c7826 Remove setting, add persistence 2023-07-08 15:14:35 -04:00
binwiederhier
ce01a66ff3 Merge remote-tracking branch 'nihalgonsalves/ng/markdown' into markdown 2023-07-07 20:53:15 -04:00
binwiederhier
7582be1a39 Merge branch 'main' into markdown 2023-07-07 20:52:31 -04:00
Nihal Gonsalves
f989fd0743 Web app: implement markdown support 2023-07-06 20:25:20 +02:00
Philipp C. Heckel
097e84aeed Merge pull request #811 from bleetube/ansible_role_ntfy
Add new integration ansible-role-ntfy-alertmanager
2023-07-05 20:43:56 -04:00
Brian Lee
faadb5148f Add new integration ansible-role-ntfy-alertmanager 2023-07-05 14:50:01 -07:00
binwiederhier
56ed4f0515 Blog post 2023-07-05 08:45:26 -04:00
binwiederhier
43981bb675 Merge branch 'main' into markdown 2023-07-04 21:15:08 -04:00
binwiederhier
cd38511ad4 Update deps 2023-07-04 20:52:39 -04:00
binwiederhier
53f13fd811 FAQ 2023-07-04 20:47:19 -04:00
binwiederhier
77cc52e4ac Remove email 2023-07-04 20:11:45 -04:00
binwiederhier
35cb4606f6 FAQ 2023-07-04 20:10:17 -04:00
binwiederhier
d01ed355e0 Changelog 2023-07-04 14:23:44 -04:00
Philipp C. Heckel
495fb24b9a Merge pull request #804 from nimbleghost/rtl
Web app: add RTL support
2023-07-04 14:20:24 -04:00
nimbleghost
311ffc3672 Format datetimes using i18n lang 2023-07-03 15:24:26 +02:00
nimbleghost
7a1488fcd3 Web app: add RTL support
Ref:

https://mui.com/material-ui/guides/right-to-left
https://m2.material.io/design/usability/bidirectionality.html
2023-07-03 15:24:26 +02:00
binwiederhier
4267c0d9b6 Update docs 2023-06-30 21:54:27 -04:00
binwiederhier
88eb728fe3 Changelog 2023-06-30 21:51:03 -04:00
binwiederhier
26c835cdd1 Install notes, background change for xs dark mode drawer 2023-06-30 09:58:56 -04:00
binwiederhier
7d3d697a20 Fix goreleaser 2023-06-30 09:30:36 -04:00
binwiederhier
798ee3c23c Merge branch 'main' of github.com:binwiederhier/ntfy 2023-06-30 08:45:44 -04:00
binwiederhier
7581058c93 Bump Go version in pipelines 2023-06-30 08:45:28 -04:00
Philipp C. Heckel
4f0ddfc30d Merge pull request #795 from nimbleghost/pwa-improvements
PWA: Fix reload, Firefox mp3 load, reduce mobile padding
2023-06-30 08:43:19 -04:00
nimbleghost
0b918464c1 Move registerSW out 2023-06-30 08:59:31 +02:00
nimbleghost
57bd37ef2f Fix sidebar colour on mobile 2023-06-29 15:22:59 +02:00
nimbleghost
9fa1288dbc Fix update behaviour 2023-06-29 15:07:18 +02:00
nimbleghost
55eed868fa Reduce padding on mobile / narrow screens 2023-06-29 13:15:06 +02:00
nimbleghost
abb1baeecd Don’t include mp3 due to Firefox sw issue 2023-06-29 13:15:06 +02:00
binwiederhier
5784b07f14 Bump 2023-06-28 20:23:10 -04:00
binwiederhier
8e1e0b3740 Overflow auto 2023-06-28 20:17:49 -04:00
binwiederhier
3f42e0e945 Merge branch 'main' into fix-permission-handling 2023-06-28 20:05:26 -04:00
binwiederhier
9146e439d2 Merge branch 'main' of github.com:binwiederhier/ntfy 2023-06-28 20:03:36 -04:00
binwiederhier
7a14a0b81f Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-06-28 20:03:32 -04:00
Philipp C. Heckel
9247475ab2 Merge pull request #793 from nimbleghost/pwa-action-bar
Make action bar match theme colour when run as PWA
2023-06-28 19:58:41 -04:00
nimbleghost
6b4c04c390 Make action bar match theme colour when run as PWA 2023-06-29 00:22:58 +02:00
nimbleghost
e8216ae9e7 Fix resubscribing when notifications are re-granted
(case: from denied to granted)
2023-06-29 00:02:18 +02:00
nimbleghost
365a0b2832 Fix preferences warnings 2023-06-28 23:38:57 +02:00
Philipp C. Heckel
f78389b6ef Merge pull request #792 from nimbleghost/fix-ntfy-banner
Fix ntfy upgrade banner in dark mode
2023-06-28 15:53:54 -04:00
nimbleghost
0d231d8bd9 Fix snackbars in dark mode 2023-06-28 21:18:04 +02:00
nimbleghost
d838790b8f Fix ntfy upgrade banner in dark mode 2023-06-28 20:43:42 +02:00
nimbleghost
9ce3545901 Fix refreshing things when permission is granted
We refreshed some things but not everything, this makes it more
responsive if you have the settings page open when granting permissions,
for example.
2023-06-28 20:26:54 +02:00
怪盗kidou
8db569e8a5 Translated using Weblate (Chinese (Simplified))
Currently translated at 94.5% (361 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2023-06-28 16:52:23 +02:00
binwiederhier
7d46f1eed9 Merge branch 'main' into markdown 2023-05-26 21:15:38 -04:00
binwiederhier
7812eb9d19 WIP: Markdown 2023-05-24 20:37:27 -04:00
47 changed files with 1464 additions and 433 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
[![Discord](https://img.shields.io/discord/874398661709295626?label=Discord)](https://discord.gg/cT7ECsZj9w) [![Discord](https://img.shields.io/discord/874398661709295626?label=Discord)](https://discord.gg/cT7ECsZj9w)
[![Matrix](https://img.shields.io/matrix/ntfy:matrix.org?label=Matrix)](https://matrix.to/#/#ntfy:matrix.org) [![Matrix](https://img.shields.io/matrix/ntfy:matrix.org?label=Matrix)](https://matrix.to/#/#ntfy:matrix.org)
[![Matrix space](https://img.shields.io/matrix/ntfy-space:matrix.org?label=Matrix+space)](https://matrix.to/#/#ntfy-space:matrix.org) [![Matrix space](https://img.shields.io/matrix/ntfy-space:matrix.org?label=Matrix+space)](https://matrix.to/#/#ntfy-space:matrix.org)
[![Lemmy](https://img.shields.io/badge/Lemmy-discuss-green)](https://discuss.ntfy.sh/) [![Lemmy](https://img.shields.io/badge/Lemmy-discuss-green)](https://discuss.ntfy.sh/c/ntfy)
[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/) [![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/)
[![Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/binwiederhier/ntfy) [![Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](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)

View File

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

View File

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

View File

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

View File

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

View 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

View File

@@ -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) (`![some image](https://bing.com/logo.png)`)
- [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>
![markdown](static/img/web-markdown.png){ 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 |

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

View File

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

View File

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

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

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

View File

@@ -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,
&timestamp, &timestamp,
@@ -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()
}

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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": "要使用来电通知功能,您需要添加并验证至少一个电话号码。可以通过短信或电话进行验证。"
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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).
*/ */

View File

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

View File

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

View File

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