Compare commits

...

36 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
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
39 changed files with 1310 additions and 335 deletions

View File

@@ -11,7 +11,7 @@ jobs:
name: Install Go
uses: actions/setup-go@v4
with:
go-version: '1.19.x'
go-version: '1.20.x'
-
name: Install node
uses: actions/setup-node@v3

View File

@@ -14,7 +14,7 @@ jobs:
name: Install Go
uses: actions/setup-go@v4
with:
go-version: '1.19.x'
go-version: '1.20.x'
-
name: Install node
uses: actions/setup-node@v3

View File

@@ -11,7 +11,7 @@ jobs:
name: Install Go
uses: actions/setup-go@v4
with:
go-version: '1.19.x'
go-version: '1.20.x'
-
name: Install node
uses: actions/setup-node@v3

View File

@@ -119,8 +119,6 @@ archives:
- server/ntfy.service
- client/client.yml
- client/ntfy-client.service
replacements:
amd64: x86_64
-
id: ntfy_windows
builds:
@@ -131,8 +129,6 @@ archives:
- LICENSE
- README.md
- client/client.yml
replacements:
amd64: x86_64
-
id: ntfy_darwin
builds:
@@ -142,8 +138,6 @@ archives:
- LICENSE
- README.md
- client/client.yml
replacements:
darwin: macOS
universal_binaries:
-
id: ntfy_darwin_all

View File

@@ -9,7 +9,7 @@
[![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 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/)
[![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
* [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
* [Email](https://heckel.io/about) - reach me directly (_I usually prefer the other methods_)
## Announcements / beta testers
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)
}
// 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
func WithFilename(filename string) PublishOption {
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: "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.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: "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"},
@@ -95,6 +96,7 @@ func execPublish(c *cli.Context) error {
icon := c.String("icon")
actions := c.String("actions")
attach := c.String("attach")
markdown := c.Bool("attach")
filename := c.String("filename")
file := c.String("file")
email := c.String("email")
@@ -140,6 +142,9 @@ func execPublish(c *cli.Context) error {
if attach != "" {
options = append(options, client.WithAttach(attach))
}
if markdown {
options = append(options, client.WithMarkdown())
}
if 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 would be humbled if you helped me carry the server and developer account costs. Even small donations are very much
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"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.1/ntfy_2.6.1_linux_x86_64.tar.gz
tar zxvf ntfy_2.6.1_linux_x86_64.tar.gz
sudo cp -a ntfy_2.6.1_linux_x86_64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_amd64.tar.gz
tar zxvf ntfy_2.6.2_linux_amd64.tar.gz
sudo cp -a ntfy_2.6.2_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.1/ntfy_2.6.1_linux_armv6.tar.gz
tar zxvf ntfy_2.6.1_linux_armv6.tar.gz
sudo cp -a ntfy_2.6.1_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.1_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv6.tar.gz
tar zxvf ntfy_2.6.2_linux_armv6.tar.gz
sudo cp -a ntfy_2.6.2_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.1/ntfy_2.6.1_linux_armv7.tar.gz
tar zxvf ntfy_2.6.1_linux_armv7.tar.gz
sudo cp -a ntfy_2.6.1_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.1_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv7.tar.gz
tar zxvf ntfy_2.6.2_linux_armv7.tar.gz
sudo cp -a ntfy_2.6.2_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.1/ntfy_2.6.1_linux_arm64.tar.gz
tar zxvf ntfy_2.6.1_linux_arm64.tar.gz
sudo cp -a ntfy_2.6.1_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.1_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_arm64.tar.gz
tar zxvf ntfy_2.6.2_linux_arm64.tar.gz
sudo cp -a ntfy_2.6.2_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -109,7 +109,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.1/ntfy_2.6.1_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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -117,7 +117,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.1/ntfy_2.6.1_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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -125,7 +125,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.1/ntfy_2.6.1_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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -133,7 +133,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.1/ntfy_2.6.1_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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -143,34 +143,36 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.1/ntfy_2.6.1_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 start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.1/ntfy_2.6.1_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 start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.1/ntfy_2.6.1_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 start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.1/ntfy_2.6.1_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 start ntfy
```
## 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
```
@@ -192,18 +194,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## macOS
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.1/ntfy_2.6.1_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`).
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).
```bash
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.6.1/ntfy_2.6.1_macOS_all.tar.gz > ntfy_2.6.1_macOS_all.tar.gz
tar zxvf ntfy_2.6.1_macOS_all.tar.gz
sudo cp -a ntfy_2.6.1_macOS_all/ntfy /usr/local/bin/ntfy
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.2_darwin_all.tar.gz
sudo cp -a ntfy_2.6.2_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.6.1_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
```
@@ -221,7 +223,7 @@ brew install ntfy
## Windows
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.1/ntfy_2.6.1_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%`.
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)
- [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)
- [ansible-role-ntfy-alertmanager](https://github.com/bleetube/ansible-role-ntfy-alertmanager) - Ansible role to install xenrox/ntfy-alertmanager
## 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
- [#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

View File

@@ -138,7 +138,7 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an
Tags = "warning,skull"
}
Body = "Remote access to phils-laptop detected. Act right away."
}
}
Invoke-RestMethod @Request
```
@@ -623,6 +623,109 @@ them with a comma, e.g. `tag1,tag2,tag3`.
as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `tag1,=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
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
_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 |
| `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) |
| `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) |
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
| `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-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-Markdown` | `Markdown`, `md` | Enable [Markdown formatting](#markdown-formatting) in the notification body |
| `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-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-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 |
| `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)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
### ntfy server v2.6.1
Released June 28, 2023
## ntfy server v2.6.2
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
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.
@@ -31,6 +32,7 @@ if you use promo code `MYTOPIC`). ntfy will always remain open source.
* 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)
* 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:**
@@ -39,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: 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
Released May 18, 2023
@@ -1241,6 +1251,16 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## 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)
**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
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
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.

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/)
- **Edge:** Windows
Note that for self-hosted servers, [Web Push](../config.md#web-push) must be configured for the PWA to work.
## Installation
### Chrome on Desktop

12
go.mod
View File

@@ -29,7 +29,7 @@ require (
firebase.google.com/go/v4 v4.11.0
github.com/SherClockHolmes/webpush-go v1.2.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 (
@@ -64,14 +64,14 @@ require (
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/net v0.11.0 // indirect
golang.org/x/sys v0.9.0 // indirect
golang.org/x/text v0.10.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/text v0.11.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/appengine/v2 v2.0.3 // indirect
google.golang.org/genproto v0.0.0-20230628200519-e449d1ea0e82 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230628200519-e449d1ea0e82 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230628200519-e449d1ea0e82 // indirect
google.golang.org/genproto v0.0.0-20230629202037-9506855d4529 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529 // indirect
google.golang.org/grpc v1.56.1 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

32
go.sum
View File

@@ -12,8 +12,6 @@ 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/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
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.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
cloud.google.com/go/storage v1.31.0 h1:+S3LjjEN2zZ+L5hOwj4+1OkGCsLVe0NzpXKQ1pSdTCI=
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=
@@ -145,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.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
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.23.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/stripe/stripe-go/v74 v74.24.0 h1:h+hXEI5avC5moAh2YLtphMFTBnp11TfXTcP4suuWDLk=
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/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
@@ -204,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-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.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28=
@@ -216,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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
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.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
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/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -244,18 +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-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-20230626202813-9b080da550b3 h1:Yofj1/U0xc/Zi5KEpoIxm51I2f85X+eGyY4YzAujRdw=
google.golang.org/genproto v0.0.0-20230626202813-9b080da550b3/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64=
google.golang.org/genproto v0.0.0-20230628200519-e449d1ea0e82 h1:Wdfp5Hc1bqGCWYZNrir4A1Jb+SmVaV2j1DL/pbMMTGI=
google.golang.org/genproto v0.0.0-20230628200519-e449d1ea0e82/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-20230626202813-9b080da550b3/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
google.golang.org/genproto/googleapis/api v0.0.0-20230628200519-e449d1ea0e82 h1:iI5Fmsfz4zDINYxJLxn2YChI//ypkHM/KuVSvlN7ZXk=
google.golang.org/genproto/googleapis/api v0.0.0-20230628200519-e449d1ea0e82/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-20230626202813-9b080da550b3/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230628200519-e449d1ea0e82 h1:6b+zGQBiXFlAMpQr+cCarAdrZD4QgXSG7uUZadYysgg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230628200519-e449d1ea0e82/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
google.golang.org/genproto v0.0.0-20230629202037-9506855d4529 h1:9JucMWR7sPvCxUFd6UsOUNmA5kCcWOfORaT3tpAsKQs=
google.golang.org/genproto v0.0.0-20230629202037-9506855d4529/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64=
google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529 h1:s5YSX+ZH5b5vS9rnpGymvIyMpLRJizowqDlOuyjXnTk=
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-20230629202037-9506855d4529 h1:DEH99RbiLZhMxrpEJCZ0A+wdTe0EOgou/poSLx9vWf4=
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.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=

View File

@@ -45,6 +45,7 @@ const (
attachment_deleted INT NOT NULL,
sender TEXT NOT NULL,
user TEXT NOT NULL,
content_type TEXT NOT NULL,
encoding TEXT NOT NULL,
published INT NOT NULL
);
@@ -63,43 +64,43 @@ const (
COMMIT;
`
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
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
WHERE mid = ?
`
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
WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time, id
`
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
WHERE topic = ? AND time >= ?
ORDER BY time, id
`
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
WHERE topic = ? AND id > ? AND published = 1
ORDER BY time, id
`
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
WHERE topic = ? AND (id > ? OR published = 0)
ORDER BY time, id
`
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
WHERE time <= ? AND published = 0
ORDER BY time, id
@@ -121,7 +122,7 @@ const (
// Schema management queries
const (
currentSchemaVersion = 11
currentSchemaVersion = 12
createSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
@@ -240,6 +241,11 @@ const (
);
INSERT INTO stats (key, value) VALUES ('messages', 0);
`
// 11 -> 12
migrate11To12AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT('');
`
)
var (
@@ -255,6 +261,7 @@ var (
8: migrateFrom8,
9: migrateFrom9,
10: migrateFrom10,
11: migrateFrom11,
}
)
@@ -384,6 +391,7 @@ func (c *messageCache) addMessages(ms []*message) error {
attachmentDeleted, // Always zero
sender,
m.User,
m.ContentType,
m.Encoding,
published,
)
@@ -656,7 +664,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
func readMessage(rows *sql.Rows) (*message, error) {
var timestamp, expires, attachmentSize, attachmentExpires int64
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(
&id,
&timestamp,
@@ -676,6 +684,7 @@ func readMessage(rows *sql.Rows) (*message, error) {
&attachmentURL,
&sender,
&user,
&contentType,
&encoding,
)
if err != nil {
@@ -706,22 +715,23 @@ func readMessage(rows *sql.Rows) (*message, error) {
}
}
return &message{
ID: id,
Time: timestamp,
Expires: expires,
Event: messageEvent,
Topic: topic,
Message: msg,
Title: title,
Priority: priority,
Tags: tags,
Click: click,
Icon: icon,
Actions: actions,
Attachment: att,
Sender: senderIP, // Must parse assuming database must be correct
User: user,
Encoding: encoding,
ID: id,
Time: timestamp,
Expires: expires,
Event: messageEvent,
Topic: topic,
Message: msg,
Title: title,
Priority: priority,
Tags: tags,
Click: click,
Icon: icon,
Actions: actions,
Attachment: att,
Sender: senderIP, // Must parse assuming database must be correct
User: user,
ContentType: contentType,
Encoding: encoding,
}, nil
}
@@ -929,7 +939,7 @@ func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
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")
tx, err := db.Begin()
if err != nil {
@@ -944,3 +954,19 @@ func migrateFrom10(db *sql.DB, cacheDuration time.Duration) error {
}
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())
}
}
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!
if unifiedpush {
firebase = false
@@ -1785,6 +1789,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
if m.Icon != "" {
r.Header.Set("X-Icon", m.Icon)
}
if m.Markdown {
r.Header.Set("X-Markdown", "yes")
}
if len(m.Actions) > 0 {
actionsStr, err := json.Marshal(m.Actions)
if err != nil {

View File

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

View File

@@ -182,6 +182,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
"title": "some title",
"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"}}]`,
"content_type": "",
"encoding": "",
"attachment_name": "some file.jpg",
"attachment_type": "image/jpeg",
@@ -203,6 +204,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
"title": "some title",
"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"}}]`,
"content_type": "",
"encoding": "",
"attachment_name": "some file.jpg",
"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)
}
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) {
s := newTestServer(t, newTestConfig(t))
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, "http://ntfy.sh", m.Click)
require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon)
require.Equal(t, "", m.ContentType)
require.Equal(t, 4, m.Priority)
require.True(t, m.Time > time.Now().Unix()+29*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) {
// Publishing as JSON follows a different path. This ensures that rate
// limiting works for this endpoint as well

View File

@@ -25,23 +25,24 @@ const (
// message represents a message published to a topic
type message struct {
ID string `json:"id"` // Random message ID
Time int64 `json:"time"` // Unix time in seconds
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
Event string `json:"event"` // One of the above
Topic string `json:"topic"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"`
Icon string `json:"icon,omitempty"`
Actions []*action `json:"actions,omitempty"`
Attachment *attachment `json:"attachment,omitempty"`
PollID string `json:"poll_id,omitempty"`
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
User string `json:"-"` // UserID of the uploader, used to associated attachments
ID string `json:"id"` // Random message ID
Time int64 `json:"time"` // Unix time in seconds
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
Event string `json:"event"` // One of the above
Topic string `json:"topic"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"`
Icon string `json:"icon,omitempty"`
Actions []*action `json:"actions,omitempty"`
Attachment *attachment `json:"attachment,omitempty"`
PollID string `json:"poll_id,omitempty"`
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
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 {
@@ -100,6 +101,7 @@ type publishMessage struct {
Icon string `json:"icon"`
Actions []action `json:"actions"`
Attach string `json:"attach"`
Markdown bool `json:"markdown"`
Filename string `json:"filename"`
Email string `json:"email"`
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/"
},
"dependencies": {
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.4.2",
@@ -26,9 +27,12 @@
"react-dom": "latest",
"react-i18next": "^11.16.2",
"react-infinite-scroll-component": "^6.1.0",
"react-remark": "^2.1.0",
"react-router-dom": "^6.2.2",
"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": {
"@vitejs/plugin-react": "^4.0.0",

View File

@@ -160,6 +160,7 @@
"publish_dialog_button_cancel_sending": "Cancel sending",
"publish_dialog_button_cancel": "Cancel",
"publish_dialog_button_send": "Send",
"publish_dialog_checkbox_markdown": "Format as Markdown",
"publish_dialog_checkbox_publish_another": "Publish another",
"publish_dialog_attached_file_title": "Attached file:",
"publish_dialog_attached_file_filename_placeholder": "Attachment filename",

View File

@@ -2,6 +2,7 @@
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import { clientsClaim } from "workbox-core";
import { dbAsync } from "../src/app/db";
@@ -224,6 +225,8 @@ precacheAndRoute(
self.__WB_MANIFEST
);
// Claim all open windows
clientsClaim();
// Delete any cached old dist files from previous service worker versions
cleanupOutdatedCaches();

View File

@@ -89,15 +89,15 @@ export const maybeWithAuth = (headers, user) => {
return headers;
};
export const maybeAppendActionErrors = (message, notification) => {
export const maybeActionErrors = (notification) => {
const actionErrors = (notification.actions ?? [])
.map((action) => action.error)
.filter((action) => !!action)
.join("\n");
if (actionErrors.length === 0) {
return message;
return undefined;
}
return `${message}\n\n${actionErrors}`;
return actionErrors;
};
export const shuffle = (arr) => {
@@ -130,13 +130,14 @@ export const hashCode = (s) => {
return hash;
};
export const formatShortDateTime = (timestamp) =>
new Intl.DateTimeFormat("default", {
export const formatShortDateTime = (timestamp, language) =>
new Intl.DateTimeFormat(language, {
dateStyle: "short",
timeStyle: "short",
}).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) => {
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 DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import i18n from "i18next";
import humanizeDuration from "humanize-duration";
import CelebrationIcon from "@mui/icons-material/Celebration";
import CloseIcon from "@mui/icons-material/Close";
@@ -224,7 +223,7 @@ const ChangePasswordDialog = (props) => {
};
const AccountType = () => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { account } = useContext(AccountContext);
const [upgradeDialogKey, setUpgradeDialogKey] = useState(0);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
@@ -283,7 +282,7 @@ const AccountType = () => {
{account.billing?.paid_until && !account.billing?.cancel_at && (
<Tooltip
title={t("account_basics_tier_paid_until", {
date: formatShortDate(account.billing?.paid_until),
date: formatShortDate(account.billing?.paid_until, i18n.language),
})}
>
<span>
@@ -328,7 +327,7 @@ const AccountType = () => {
{account.billing?.cancel_at > 0 && (
<Alert severity="warning" sx={{ mt: 1 }}>
{t("account_basics_tier_canceled_subscription", {
date: formatShortDate(account.billing.cancel_at),
date: formatShortDate(account.billing.cancel_at, i18n.language),
})}
</Alert>
)}
@@ -556,7 +555,7 @@ const AddPhoneNumberDialog = (props) => {
};
const Stats = () => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { account } = useContext(AccountContext);
if (!account) {
@@ -798,7 +797,7 @@ const Tokens = () => {
};
const TokensTable = (props) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [snackOpen, setSnackOpen] = useState(false);
const [upsertDialogKey, setUpsertDialogKey] = useState(0);
const [upsertDialogOpen, setUpsertDialogOpen] = useState(false);
@@ -872,11 +871,11 @@ const TokensTable = (props) => {
{token.token !== session.token() && (token.label || "-")}
</TableCell>
<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 sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
<div style={{ display: "flex", alignItems: "center" }}>
<span>{formatShortDateTime(token.last_access)}</span>
<span>{formatShortDateTime(token.last_access, i18n.language)}</span>
<Tooltip
title={t("account_tokens_table_last_origin_tooltip", {
ip: token.last_origin,

View File

@@ -1,9 +1,9 @@
import * as React from "react";
import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react";
import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery } from "@mui/material";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from "@mui/material";
import { useLiveQuery } from "dexie-react-hooks";
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { AllSubscriptions, SingleSubscription } from "./Notifications";
import { darkTheme, lightTheme } from "./theme";
import Navigation from "./Navigation";
@@ -22,6 +22,7 @@ import Signup from "./Signup";
import Account from "./Account";
import "../app/i18n"; // Translations!
import prefs, { THEME } from "../app/Prefs";
import RTLCacheProvider from "./RTLCacheProvider";
export const AccountContext = createContext(null);
@@ -40,37 +41,47 @@ const darkModeEnabled = (prefersDarkMode, themePreference) => {
};
const App = () => {
const { i18n } = useTranslation();
const languageDir = i18n.dir();
const [account, setAccount] = useState(null);
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const themePreference = useLiveQuery(() => prefs.theme());
const theme = React.useMemo(
() => createTheme(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme),
[prefersDarkMode, themePreference]
() => createTheme({ ...(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), direction: languageDir }),
[prefersDarkMode, themePreference, languageDir]
);
useEffect(() => {
document.documentElement.setAttribute("lang", i18n.language);
document.dir = languageDir;
}, [i18n.language, languageDir]);
return (
<Suspense fallback={<Loader />}>
<BrowserRouter>
<ThemeProvider theme={theme}>
<AccountContext.Provider value={accountMemo}>
<CssBaseline />
<ErrorBoundary>
<Routes>
<Route path={routes.login} element={<Login />} />
<Route path={routes.signup} element={<Signup />} />
<Route element={<Layout />}>
<Route path={routes.app} element={<AllSubscriptions />} />
<Route path={routes.account} element={<Account />} />
<Route path={routes.settings} element={<Preferences />} />
<Route path={routes.subscription} element={<SingleSubscription />} />
<Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
</Route>
</Routes>
</ErrorBoundary>
</AccountContext.Provider>
</ThemeProvider>
</BrowserRouter>
<RTLCacheProvider>
<BrowserRouter>
<ThemeProvider theme={theme}>
<AccountContext.Provider value={accountMemo}>
<CssBaseline />
<ErrorBoundary>
<Routes>
<Route path={routes.login} element={<Login />} />
<Route path={routes.signup} element={<Signup />} />
<Route element={<Layout />}>
<Route path={routes.app} element={<AllSubscriptions />} />
<Route path={routes.account} element={<Account />} />
<Route path={routes.settings} element={<Preferences />} />
<Route path={routes.subscription} element={<SingleSubscription />} />
<Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
</Route>
</Routes>
</ErrorBoundary>
</AccountContext.Provider>
</ThemeProvider>
</BrowserRouter>
</RTLCacheProvider>
</Suspense>
);
};
@@ -133,7 +144,7 @@ const Main = (props) => (
display: "flex",
flexGrow: 1,
flexDirection: "column",
padding: 3,
padding: { xs: 0, md: 3 },
width: { sm: `calc(100% - ${Navigation.width}px)` },
height: "100dvh",
overflow: "auto",

View File

@@ -61,7 +61,7 @@ const Navigation = (props) => {
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
sx={{
display: { xs: "block", sm: "none" },
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
"& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth, backgroundImage: "none" },
}}
>
{navigationList}

View File

@@ -24,7 +24,9 @@ import { useLiveQuery } from "dexie-react-hooks";
import InfiniteScroll from "react-infinite-scroll-component";
import { Trans, useTranslation } from "react-i18next";
import { useOutletContext } from "react-router-dom";
import { formatBytes, 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 { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
import subscriptionManager from "../app/SubscriptionManager";
@@ -159,11 +161,72 @@ const autolink = (s) => {
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 { t } = useTranslation();
const { t, i18n } = useTranslation();
const { notification } = props;
const { attachment } = notification;
const date = formatShortDateTime(notification.time);
const date = formatShortDateTime(notification.time, i18n.language);
const otherTags = unmatchedTags(notification.tags);
const tags = otherTags.length > 0 ? otherTags.join(", ") : null;
const handleDelete = async () => {
@@ -183,8 +246,9 @@ const NotificationItem = (props) => {
const hasClickAction = notification.click;
const hasUserActions = notification.actions && notification.actions.length > 0;
const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
return (
<Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
<Card sx={{ padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
<CardContent>
<Tooltip title={t("notifications_delete")} enterDelay={500}>
<IconButton onClick={handleDelete} sx={{ float: "right", marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}>
@@ -230,7 +294,8 @@ const NotificationItem = (props) => {
</Typography>
)}
<Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
{autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
<NotificationBody notification={notification} />
{maybeActionErrors(notification)}
</Typography>
{attachment && <Attachment attachment={attachment} />}
{tags && (
@@ -277,7 +342,7 @@ const NotificationItem = (props) => {
};
const Attachment = (props) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { attachment } = props;
const expired = 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) {
infos.push(
t("notifications_attachment_link_expires", {
date: formatShortDateTime(attachment.expires),
date: formatShortDateTime(attachment.expires, i18n.language),
})
);
}

View File

@@ -61,6 +61,7 @@ const PublishDialog = (props) => {
const [call, setCall] = useState("");
const [delay, setDelay] = useState("");
const [publishAnother, setPublishAnother] = useState(false);
const [markdownEnabled, setMarkdownEnabled] = useState(false);
const [showTopicUrl, setShowTopicUrl] = useState("");
const [showClickUrl, setShowClickUrl] = useState(false);
@@ -148,6 +149,10 @@ const PublishDialog = (props) => {
if (attachFile && message.trim()) {
url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
}
if (markdownEnabled) {
url.searchParams.append("markdown", "true");
}
const body = attachFile || message;
try {
const user = await userManager.get(baseUrl);
@@ -353,6 +358,20 @@ const PublishDialog = (props) => {
"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" }}>
<EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} />
<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];
const nowSeconds = Math.round(Date.now() / 1000);
const message = shuffle([
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(
nowSeconds,
"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.`,
`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 ...`,
`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?`,

View File

@@ -62,7 +62,7 @@ const Banner = {
const UpgradeDialog = (props) => {
const theme = useTheme();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { account } = useContext(AccountContext); // May be undefined!
const [error, setError] = useState("");
const [tiers, setTiers] = useState(null);
@@ -233,7 +233,7 @@ const UpgradeDialog = (props) => {
<Trans
i18nKey="account_upgrade_dialog_cancel_warning"
values={{
date: formatShortDate(account?.billing?.paid_until || 0),
date: formatShortDate(account?.billing?.paid_until || 0, i18n.language),
}}
/>
</Alert>

View File

@@ -1,6 +1,9 @@
import * as React from "react";
import { createRoot } from "react-dom/client";
import App from "./components/App";
import registerSW from "./registerSW";
registerSW();
const root = createRoot(document.querySelector("#root"));
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(),
VitePWA({
registerType: "autoUpdate",
injectRegister: "inline",
// see registerSW.js imported by index.jsx
injectRegister: null,
strategies: "injectManifest",
devOptions: {
enabled: true,
@@ -25,7 +26,7 @@ export default defineConfig(({ mode }) => ({
navigateFallback: "index.html",
},
injectManifest: {
globPatterns: ["**/*.{js,css,html,mp3,ico,png,svg,json}"],
globPatterns: ["**/*.{js,css,html,ico,png,svg,json}"],
globIgnores: ["config.js"],
manifestTransforms: [
(entries) => ({