mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-01-19 00:27:25 +01:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3baa1a9fd0 | ||
|
|
c1517e259d | ||
|
|
66d30fb42a | ||
|
|
ea30132763 | ||
|
|
1c237435ec | ||
|
|
c37793d06f | ||
|
|
57c7a353b5 | ||
|
|
8154705f0b | ||
|
|
fd2c6ef590 | ||
|
|
6ece25e7f3 | ||
|
|
a2ef6180bb | ||
|
|
c3c4c9e9aa | ||
|
|
031c848984 | ||
|
|
1f70ff1b06 | ||
|
|
1af9a85847 | ||
|
|
e474d1e8b0 | ||
|
|
4b117a790a | ||
|
|
72a2a8c82e | ||
|
|
a7af16beb1 | ||
|
|
7d38bc7654 | ||
|
|
bcbcbf12ac | ||
|
|
8b5d2a8ca0 | ||
|
|
9b8e637618 | ||
|
|
15a45d9eb7 | ||
|
|
8a7bc38861 | ||
|
|
2d96560375 | ||
|
|
bb5e0e3fed | ||
|
|
4a8678bf39 | ||
|
|
ed28082c01 | ||
|
|
0d3dcfdc7a | ||
|
|
672203467d | ||
|
|
4ce619f9cb | ||
|
|
5344337b43 | ||
|
|
cf3238859c | ||
|
|
9a03a9e81b | ||
|
|
edfed24c27 | ||
|
|
7118dcc124 | ||
|
|
5bcb35f756 | ||
|
|
eaf3c42227 | ||
|
|
16a4feaeb6 | ||
|
|
b60458318c | ||
|
|
b10c88afd7 | ||
|
|
f0cae0fbac | ||
|
|
28bb8d4446 | ||
|
|
adea3c38be | ||
|
|
fb56ab9a06 | ||
|
|
72aea2613a | ||
|
|
6bd4e4bd7c | ||
|
|
1f6118f068 | ||
|
|
574e72a974 | ||
|
|
53646737e8 | ||
|
|
26b9cc75ca | ||
|
|
66e46aaded | ||
|
|
ddf5d49895 |
@@ -138,14 +138,24 @@ dockers:
|
||||
goarm: 7
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm/v7"
|
||||
- image_templates:
|
||||
- &armv6_image "binwiederhier/ntfy:{{ .Tag }}-armv6"
|
||||
use: buildx
|
||||
dockerfile: Dockerfile
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm/v6"
|
||||
docker_manifests:
|
||||
- name_template: "binwiederhier/ntfy:latest"
|
||||
image_templates:
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- *armv6_image
|
||||
- name_template: "binwiederhier/ntfy:{{ .Tag }}"
|
||||
image_templates:
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- *armv6_image
|
||||
|
||||
@@ -519,24 +519,27 @@ or the root domain:
|
||||
```
|
||||
<VirtualHost *:80>
|
||||
ServerName ntfy.sh
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
|
||||
# Proxy connections to ntfy (requires "a2enmod proxy")
|
||||
ProxyPass / http://127.0.0.1:2586/
|
||||
ProxyPassReverse / http://127.0.0.1:2586/
|
||||
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
# Higher than the max message size of 4096 bytes
|
||||
LimitRequestBody 102400
|
||||
|
||||
# WebSockets support
|
||||
# Enable mod_rewrite (requires "a2enmod rewrite")
|
||||
RewriteEngine on
|
||||
|
||||
# WebSockets support (requires "a2enmod rewrite proxy_wstunnel")
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
|
||||
|
||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||
# it to work with curl without the annoying https:// prefix
|
||||
RewriteEngine on
|
||||
RewriteCond %{REQUEST_METHOD} GET
|
||||
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L]
|
||||
</VirtualHost>
|
||||
@@ -548,26 +551,24 @@ or the root domain:
|
||||
SSLCertificateFile /etc/letsencrypt/live/ntfy.sh/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
|
||||
# Proxy connections to ntfy (requires "a2enmod proxy")
|
||||
ProxyPass / http://127.0.0.1:2586/
|
||||
ProxyPassReverse / http://127.0.0.1:2586/
|
||||
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
# Higher than the max message size of 4096 bytes
|
||||
LimitRequestBody 102400
|
||||
|
||||
# WebSockets support
|
||||
# Enable mod_rewrite (requires "a2enmod rewrite")
|
||||
RewriteEngine on
|
||||
|
||||
# WebSockets support (requires "a2enmod rewrite proxy_wstunnel")
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
|
||||
|
||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||
# it to work with curl without the annoying https:// prefix
|
||||
RewriteEngine on
|
||||
RewriteCond %{REQUEST_METHOD} GET
|
||||
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L]
|
||||
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
@@ -578,6 +579,15 @@ or the root domain:
|
||||
|
||||
ntfy.sh, http://nfty.sh {
|
||||
reverse_proxy 127.0.0.1:2586
|
||||
|
||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||
# it to work with curl without the annoying https:// prefix
|
||||
@httpget {
|
||||
protocol http
|
||||
method GET
|
||||
path_regexp ^/([-_a-z0-9]{0,64}$|docs/|static/)
|
||||
}
|
||||
redir @httpget https://{host}{uri}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -26,37 +26,37 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.0/ntfy_1.21.0_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_1.21.0_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_1.21.0_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_1.21.2_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_1.21.2_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.0/ntfy_1.21.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_1.21.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_1.21.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_1.21.2_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_1.21.2_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.0/ntfy_1.21.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_1.21.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_1.21.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_1.21.2_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_1.21.2_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.0/ntfy_1.21.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_1.21.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_1.21.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_1.21.2_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_1.21.2_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -75,18 +75,6 @@ Installation via Debian repository:
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
curl -sSL https://archive.heckel.io/apt/pubkey.txt | sudo apt-key add -
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
curl -sSL https://archive.heckel.io/apt/pubkey.txt | sudo apt-key add -
|
||||
@@ -115,7 +103,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.0/ntfy_1.21.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -123,7 +111,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.0/ntfy_1.21.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -131,7 +119,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.0/ntfy_1.21.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -139,7 +127,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.0/ntfy_1.21.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -149,28 +137,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.0/ntfy_1.21.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.0/ntfy_1.21.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.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/v1.21.0/ntfy_1.21.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.0/ntfy_1.21.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -189,8 +177,8 @@ makepkg -si
|
||||
```
|
||||
|
||||
## Docker
|
||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
|
||||
straight forward to use.
|
||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should
|
||||
be pretty straight forward to use.
|
||||
|
||||
The server exposes its web UI and the API on port 80, so you need to expose that in Docker. To use the persistent
|
||||
[message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings,
|
||||
|
||||
193
docs/publish.md
193
docs/publish.md
@@ -163,6 +163,138 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an
|
||||
<figcaption>Urgent notification with tags and title</figcaption>
|
||||
</figure>
|
||||
|
||||
You can also do multi-line messages. Here's an example using a [click action](#click-action), an [action button](#action-buttons),
|
||||
an [external image attachment](#attach-file-from-a-url) and [email publishing](#e-mail-publishing):
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
curl \
|
||||
-H "Click: https://home.nest.com/" \
|
||||
-H "Attach: https://nest.com/view/yAxkasd.jpg" \
|
||||
-H "Actions: http, Open door, https://api.nest.com/open/yAxkasd, clear=true" \
|
||||
-H "Email: phil@example.com" \
|
||||
-d "There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell." \
|
||||
ntfy.sh/mydoorbell
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
ntfy publish \
|
||||
--click="https://home.nest.com/" \
|
||||
--attach="https://nest.com/view/yAxkasd.jpg" \
|
||||
--actions="http, Open door, https://api.nest.com/open/yAxkasd, clear=true" \
|
||||
--email="phil@example.com" \
|
||||
mydoorbell \
|
||||
"There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell."
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
POST /mydoorbell HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
Click: https://home.nest.com/
|
||||
Attach: https://nest.com/view/yAxkasd.jpg
|
||||
Actions: http, Open door, https://api.nest.com/open/yAxkasd, clear=true
|
||||
Email: phil@example.com
|
||||
|
||||
There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell.
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
fetch('https://ntfy.sh/mydoorbell', {
|
||||
method: 'POST', // PUT works too
|
||||
headers: {
|
||||
'Click': 'https://home.nest.com/',
|
||||
'Attach': 'https://nest.com/view/yAxkasd.jpg',
|
||||
'Actions': 'http, Open door, https://api.nest.com/open/yAxkasd, clear=true',
|
||||
'Email': 'phil@example.com'
|
||||
},
|
||||
body: `There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell.`,
|
||||
})
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
``` go
|
||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/mydoorbell",
|
||||
strings.NewReader(`There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell.`))
|
||||
req.Header.Set("Click", "https://home.nest.com/")
|
||||
req.Header.Set("Attach", "https://nest.com/view/yAxkasd.jpg")
|
||||
req.Header.Set("Actions", "http, Open door, https://api.nest.com/open/yAxkasd, clear=true")
|
||||
req.Header.Set("Email", "phil@example.com")
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/mydoorbell"
|
||||
$headers = @{ Click="https://home.nest.com/"
|
||||
Attach="https://nest.com/view/yAxkasd.jpg"
|
||||
Actions="http, Open door, https://api.nest.com/open/yAxkasd, clear=true"
|
||||
Email="phil@example.com" }
|
||||
$body = @'
|
||||
There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell.
|
||||
'@
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.post("https://ntfy.sh/mydoorbell",
|
||||
data="""There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell.""".encode('utf-8'),
|
||||
headers={
|
||||
"Click": "https://home.nest.com/",
|
||||
"Attach": "https://nest.com/view/yAxkasd.jpg",
|
||||
"Actions": "http, Open door, https://api.nest.com/open/yAxkasd, clear=true",
|
||||
"Email": "phil@example.com"
|
||||
})
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
``` php-inline
|
||||
file_get_contents('https://ntfy.sh/mydoorbell', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST', // PUT also works
|
||||
'header' =>
|
||||
"Content-Type: text/plain\r\n" .
|
||||
"Click: https://home.nest.com/\r\n" .
|
||||
"Attach: https://nest.com/view/yAxkasd.jpg\r\n" .
|
||||
"Actions": "http, Open door, https://api.nest.com/open/yAxkasd, clear=true\r\n" .
|
||||
"Email": "phil@example.com\r\n",
|
||||
'content' => 'There\'s someone at the door. 🐶
|
||||
|
||||
Please check if it\'s a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell.'
|
||||
]
|
||||
]));
|
||||
```
|
||||
|
||||
<figure markdown>
|
||||
{ width=500 }
|
||||
<figcaption>Notification using a click action, a user action, with an external image attachment and forwarded via email</figcaption>
|
||||
</figure>
|
||||
|
||||
## Message title
|
||||
The notification title is typically set to the topic short URL (e.g. `ntfy.sh/mytopic`). To override the title,
|
||||
you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
|
||||
@@ -850,27 +982,32 @@ To define actions using the `X-Actions` header (or any of its aliases: `Actions`
|
||||
<action1>, <label1>, paramN=... [; <action2>, <label2>, ...]
|
||||
```
|
||||
|
||||
The `action=` and `label=` prefix are optional in all actions, and the `url=` prefix is optional in the `view` and `http` action.
|
||||
The format has **some limitations**: You cannot use `,` or `;` in any of the values, and depending on your language/library, UTF-8
|
||||
characters may not work. Use the [JSON array format](#using-a-json-array) instead to overcome these limitations.
|
||||
Multiple actions are separated by a semicolon (`;`), and key/value pairs are separated by commas (`,`). Values may be
|
||||
quoted with double quotes (`"`) or single quotes (`'`) if the value itself contains commas or semicolons.
|
||||
|
||||
The `action=` and `label=` prefix are optional in all actions, and the `url=` prefix is optional in the `view` and
|
||||
`http` action. The only limitation of this format is that depending on your language/library, UTF-8 characters may not
|
||||
work. If they don't, use the [JSON array format](#using-a-json-array) instead.
|
||||
|
||||
As an example, here's how you can create the above notification using this format. Refer to the [`view` action](#open-websiteapp) and
|
||||
[`http` action](#send-http-request) section for details on the specific actions:
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
body='{"temperature": 65}'
|
||||
curl \
|
||||
-d "You left the house. Turn down the A/C?" \
|
||||
-H "Actions: view, Open portal, https://home.nest.com/, clear=true; \
|
||||
http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" \
|
||||
ntfy.sh/myhome
|
||||
http, Turn down, https://api.nest.com/, body='$body'" \
|
||||
ntfy.sh/myhome
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
body='{"temperature": 65}'
|
||||
ntfy publish \
|
||||
--actions="view, Open portal, https://home.nest.com/, clear=true; \
|
||||
http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" \
|
||||
http, Turn down, https://api.nest.com/, body='$body'" \
|
||||
myhome \
|
||||
"You left the house. Turn down the A/C?"
|
||||
```
|
||||
@@ -879,7 +1016,7 @@ As an example, here's how you can create the above notification using this forma
|
||||
``` http
|
||||
POST /myhome HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65
|
||||
Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{"temperature": 65}'
|
||||
|
||||
You left the house. Turn down the A/C?
|
||||
```
|
||||
@@ -890,7 +1027,7 @@ As an example, here's how you can create the above notification using this forma
|
||||
method: 'POST',
|
||||
body: 'You left the house. Turn down the A/C?',
|
||||
headers: {
|
||||
'Actions': 'view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65'
|
||||
'Actions': 'view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body=\'{"temperature": 65}\''
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -898,14 +1035,14 @@ As an example, here's how you can create the above notification using this forma
|
||||
=== "Go"
|
||||
``` go
|
||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("You left the house. Turn down the A/C?"))
|
||||
req.Header.Set("Actions", "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65")
|
||||
req.Header.Set("Actions", "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'")
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/myhome"
|
||||
$headers = @{ Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" }
|
||||
$headers = @{ Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" }
|
||||
$body = "You left the house. Turn down the A/C?"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
```
|
||||
@@ -914,7 +1051,7 @@ As an example, here's how you can create the above notification using this forma
|
||||
``` python
|
||||
requests.post("https://ntfy.sh/myhome",
|
||||
data="You left the house. Turn down the A/C?",
|
||||
headers={ "Actions": "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" })
|
||||
headers={ "Actions": "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" })
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
@@ -924,7 +1061,7 @@ As an example, here's how you can create the above notification using this forma
|
||||
'method' => 'POST',
|
||||
'header' =>
|
||||
"Content-Type: text/plain\r\n" .
|
||||
"Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65",
|
||||
"Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'",
|
||||
'content' => 'You left the house. Turn down the A/C?'
|
||||
]
|
||||
]));
|
||||
@@ -950,8 +1087,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
{
|
||||
"action": "http",
|
||||
"label": "Turn down",
|
||||
"url": "https://api.nest.com/device/XZ1D2",
|
||||
"body": "target_temp_f=65"
|
||||
"url": "https://api.nest.com/",
|
||||
"body": "{\"temperature\": 65}"
|
||||
}
|
||||
]
|
||||
}'
|
||||
@@ -970,8 +1107,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
{
|
||||
"action": "http",
|
||||
"label": "Turn down",
|
||||
"url": "https://api.nest.com/device/XZ1D2",
|
||||
"body": "target_temp_f=65"
|
||||
"url": "https://api.nest.com/",
|
||||
"body": "{\"temperature\": 65}"
|
||||
}
|
||||
]' \
|
||||
myhome \
|
||||
@@ -996,8 +1133,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
{
|
||||
"action": "http",
|
||||
"label": "Turn down",
|
||||
"url": "https://api.nest.com/device/XZ1D2",
|
||||
"body": "target_temp_f=65"
|
||||
"url": "https://api.nest.com/",
|
||||
"body": "{\"temperature\": 65}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1020,8 +1157,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
{
|
||||
action: "http",
|
||||
label: "Turn down",
|
||||
url: "https://api.nest.com/device/XZ1D2",
|
||||
body: "target_temp_f=65"
|
||||
url: "https://api.nest.com/",
|
||||
body: "{\"temperature\": 65}"
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -1046,8 +1183,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
{
|
||||
"action": "http",
|
||||
"label": "Turn down",
|
||||
"url": "https://api.nest.com/device/XZ1D2",
|
||||
"body": "target_temp_f=65"
|
||||
"url": "https://api.nest.com/",
|
||||
"body": "{\"temperature\": 65}"
|
||||
}
|
||||
]
|
||||
}`
|
||||
@@ -1071,8 +1208,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
@{
|
||||
"action"="http",
|
||||
"label"="Turn down"
|
||||
"url"="https://api.nest.com/device/XZ1D2"
|
||||
"body"="target_temp_f=65"
|
||||
"url"="https://api.nest.com/"
|
||||
"body"="{\"temperature\": 65}"
|
||||
}
|
||||
)
|
||||
} | ConvertTo-Json
|
||||
@@ -1095,8 +1232,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
{
|
||||
"action": "http",
|
||||
"label": "Turn down",
|
||||
"url": "https://api.nest.com/device/XZ1D2",
|
||||
"body": "target_temp_f=65"
|
||||
"url": "https://api.nest.com/",
|
||||
"body": "{\"temperature\": 65}"
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -1122,11 +1259,11 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
[
|
||||
"action": "http",
|
||||
"label": "Turn down",
|
||||
"url": "https://api.nest.com/device/XZ1D2",
|
||||
"url": "https://api.nest.com/",
|
||||
"headers": [
|
||||
"Authorization": "Bearer ..."
|
||||
],
|
||||
"body": "target_temp_f=65"
|
||||
"body": "{\"temperature\": 65}"
|
||||
]
|
||||
]
|
||||
])
|
||||
|
||||
@@ -4,34 +4,93 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
<!--
|
||||
|
||||
## ntfy Android app v1.12.0 (UNRELEASED)
|
||||
## ntfy Android app v1.13.0 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134),
|
||||
thanks to [@mrherman](https://github.com/mrherman) for reporting)
|
||||
* Support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links) ([#20](https://github.com/binwiederhier/ntfy/issues/20), thanks
|
||||
* Cards in notification detail view ([#175](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@cmeis](https://github.com/cmeis) for reporting)
|
||||
|
||||
**Bugs:**
|
||||
|
||||
* Accurate naming of "mute notifications" from "pause notifications" ([#224](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
||||
* Make messages with links selectable ([#226](https://github.com/binwiederhier/ntfy/issues/226), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||
* Restoring topics or settings from backup doesn't work ([#223](https://github.com/binwiederhier/ntfy/issues/223), thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
||||
* Fix app icon on old Android versions ([#128](https://github.com/binwiederhier/ntfy/issues/128), thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
||||
* Fix races in UnifiedPush registration ([#230](https://github.com/binwiederhier/ntfy/issues/230), thanks to @Jakob for reporting)
|
||||
* Prevent view action from crashing the app ([#233](https://github.com/binwiederhier/ntfy/issues/233))
|
||||
|
||||
**Thanks for testing:**
|
||||
|
||||
Thanks to [@cmeis](https://github.com/cmeis), [@StoyanDimitrov](https://github.com/StoyanDimitrov), [@Fallenbagel](https://github.com/Fallenbagel) for testing, and
|
||||
to [@Joeharrison94](https://github.com/Joeharrison94) for the input.
|
||||
|
||||
## ntfy server v1.22.0 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Better parsing of the user actions, allowing quotes (no ticket)
|
||||
* Make web app more accessible ([#217](https://github.com/binwiederhier/ntfy/issues/217))
|
||||
|
||||
**Bugs:**
|
||||
|
||||
* `Upgrade` header check is now case in-sensitive ([#228](https://github.com/binwiederhier/ntfy/issues/228), thanks to [@wunter8](https://github.com/wunter8) for finding it)
|
||||
* Made web app sounds quieter ([#222](https://github.com/binwiederhier/ntfy/issues/222))
|
||||
* Add "private browsing"-specific error message for Firefox/Safari ([#208](https://github.com/binwiederhier/ntfy/issues/208), thanks to [@julianfoad](https://github.com/julianfoad) for reporting)
|
||||
|
||||
**Documentation:**
|
||||
|
||||
* Improved caddy configuration (no ticket, thanks to @Stnby)
|
||||
* Additional multi-line examples on the [publish page](https://ntfy.sh/docs/publish/) ([#234](https://github.com/binwiederhier/ntfy/pull/234), thanks to [@aTable](https://github.com/aTable))
|
||||
|
||||
**Additional translations:**
|
||||
|
||||
* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/))
|
||||
* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/))
|
||||
|
||||
**Thanks for testing:**
|
||||
|
||||
Thanks to [@wunter8](https://github.com/wunter8) for testing.
|
||||
|
||||
-->
|
||||
|
||||
## ntfy Android app v1.12.0
|
||||
Released Apr 25, 2022
|
||||
|
||||
The main feature in this Android release is [Action Buttons](https://ntfy.sh/docs/publish/#action-buttons), a feature
|
||||
that allows users to add actions to the notifications. Actions can be to view a website or app, send a broadcast, or
|
||||
send a HTTP request.
|
||||
|
||||
We also added support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links), added three more
|
||||
languages and fixed a ton of bugs.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134),
|
||||
thanks to [@mrherman](https://github.com/mrherman) for reporting)
|
||||
* Support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links) ([#20](https://github.com/binwiederhier/ntfy/issues/20), thanks
|
||||
to [@Copephobia](https://github.com/Copephobia) for reporting)
|
||||
* [Fastlane metadata](https://hosted.weblate.org/projects/ntfy/android-fastlane/) can now be translated too ([#198](https://github.com/binwiederhier/ntfy/issues/198),
|
||||
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||
* Channel settings option to configure DND override, sounds, etc. ([#91](https://github.com/binwiederhier/ntfy/issues/91))
|
||||
|
||||
**Bugs:**
|
||||
|
||||
* Validate URLs when changing default server and server in user management ([#193](https://github.com/binwiederhier/ntfy/issues/193),
|
||||
* Validate URLs when changing default server and server in user management ([#193](https://github.com/binwiederhier/ntfy/issues/193),
|
||||
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||
* Error in sending test notification in different languages ([#209](https://github.com/binwiederhier/ntfy/issues/209),
|
||||
* Error in sending test notification in different languages ([#209](https://github.com/binwiederhier/ntfy/issues/209),
|
||||
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||
* "[x] Instant delivery in doze mode" checkbox does not work properly ([#211](https://github.com/binwiederhier/ntfy/issues/211))
|
||||
* Disallow "http" GET/HEAD actions with body ([#221](https://github.com/binwiederhier/ntfy/issues/221), thanks to
|
||||
* Disallow "http" GET/HEAD actions with body ([#221](https://github.com/binwiederhier/ntfy/issues/221), thanks to
|
||||
[@cmeis](https://github.com/cmeis) for reporting)
|
||||
* Action "view" with "clear=true" does not work on some phones ([#220](https://github.com/binwiederhier/ntfy/issues/220), thanks to
|
||||
* Action "view" with "clear=true" does not work on some phones ([#220](https://github.com/binwiederhier/ntfy/issues/220), thanks to
|
||||
[@cmeis](https://github.com/cmeis) for reporting)
|
||||
* Do not group foreground service notification with others ([#219](https://github.com/binwiederhier/ntfy/issues/219), thanks to
|
||||
* Do not group foreground service notification with others ([#219](https://github.com/binwiederhier/ntfy/issues/219), thanks to
|
||||
[@s-h-a-r-d](https://github.com/s-h-a-r-d) for reporting)
|
||||
|
||||
**Additional translations:**
|
||||
|
||||
* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/))
|
||||
* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/))
|
||||
* Japanese (thanks to [@shak](https://hosted.weblate.org/user/shak/))
|
||||
* Russian (thanks to [@flamey](https://hosted.weblate.org/user/flamey/) and [@ilya.mikheev.coder](https://hosted.weblate.org/user/ilya.mikheev.coder/))
|
||||
|
||||
@@ -40,12 +99,14 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
Thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d) (aka @Shard), [@cmeis](https://github.com/cmeis),
|
||||
@poblabs, and everyone I forgot for testing.
|
||||
|
||||
-->
|
||||
|
||||
|
||||
## ntfy server v1.21.0
|
||||
## ntfy server v1.21.2
|
||||
Released Apr 24, 2022
|
||||
|
||||
In this release, the web app got translation support and was translated into 9 languages already 🇧🇬 🇩🇪 🇺🇸 🌎.
|
||||
It also re-adds support for ARMv6, and adds server-side support for Action Buttons. [Action Buttons](https://ntfy.sh/docs/publish/#action-buttons)
|
||||
is a feature that will be released in the Android app soon. It allows users to add actions to the notifications.
|
||||
Limited support is available in the web app.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134),
|
||||
@@ -83,7 +144,6 @@ pip3 install apprise
|
||||
apprise -b "Hi there" ntfys://mytopic
|
||||
```
|
||||
|
||||
|
||||
## ntfy Android app v1.11.0
|
||||
Released Apr 7, 2022
|
||||
|
||||
|
||||
BIN
docs/static/img/android-screenshot-notification-multiline.jpg
vendored
Normal file
BIN
docs/static/img/android-screenshot-notification-multiline.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
307
server/actions.go
Normal file
307
server/actions.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/util"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
actionIDLength = 10
|
||||
actionEOF = rune(0)
|
||||
actionsMax = 3
|
||||
)
|
||||
|
||||
const (
|
||||
actionView = "view"
|
||||
actionBroadcast = "broadcast"
|
||||
actionHTTP = "http"
|
||||
)
|
||||
|
||||
var (
|
||||
actionsAll = []string{actionView, actionBroadcast, actionHTTP}
|
||||
actionsWithURL = []string{actionView, actionHTTP}
|
||||
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
||||
)
|
||||
|
||||
type actionParser struct {
|
||||
input string
|
||||
pos int
|
||||
}
|
||||
|
||||
// parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
|
||||
// It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON),
|
||||
// and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).
|
||||
func parseActions(s string) (actions []*action, err error) {
|
||||
// Parse JSON or simple format
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "[") {
|
||||
actions, err = parseActionsFromJSON(s)
|
||||
} else {
|
||||
actions, err = parseActionsFromSimple(s)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add ID field, ensure correct uppercase/lowercase
|
||||
for i := range actions {
|
||||
actions[i].ID = util.RandomString(actionIDLength)
|
||||
actions[i].Action = strings.ToLower(actions[i].Action)
|
||||
actions[i].Method = strings.ToUpper(actions[i].Method)
|
||||
}
|
||||
|
||||
// Validate
|
||||
if len(actions) > actionsMax {
|
||||
return nil, fmt.Errorf("only %d actions allowed", actionsMax)
|
||||
}
|
||||
for _, action := range actions {
|
||||
if !util.InStringList(actionsAll, action.Action) {
|
||||
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
|
||||
} else if action.Label == "" {
|
||||
return nil, fmt.Errorf("parameter 'label' is required")
|
||||
} else if util.InStringList(actionsWithURL, action.Action) && action.URL == "" {
|
||||
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
|
||||
} else if action.Action == actionHTTP && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
||||
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
|
||||
}
|
||||
}
|
||||
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
// parseActionsFromJSON converts a JSON array into an array of actions
|
||||
func parseActionsFromJSON(s string) ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
if err := json.Unmarshal([]byte(s), &actions); err != nil {
|
||||
return nil, fmt.Errorf("JSON error: %w", err)
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
// parseActionsFromSimple parses the "simple" actions string (as described in
|
||||
// https://ntfy.sh/docs/publish/#action-buttons), into an array of actions.
|
||||
//
|
||||
// It can parse an actions string like this:
|
||||
// view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ...
|
||||
//
|
||||
// It works by advancing the position ("pos") through the input string ("input").
|
||||
//
|
||||
// The parser is heavily inspired by https://go.dev/src/text/template/parse/lex.go (which
|
||||
// is described by Rob Pike in this video: https://www.youtube.com/watch?v=HxaD_trXwRE),
|
||||
// though it does not use state functions at all.
|
||||
//
|
||||
// Other resources:
|
||||
// https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html
|
||||
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
|
||||
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
|
||||
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
|
||||
func parseActionsFromSimple(s string) ([]*action, error) {
|
||||
if !utf8.ValidString(s) {
|
||||
return nil, errors.New("invalid utf-8 string")
|
||||
}
|
||||
parser := &actionParser{
|
||||
pos: 0,
|
||||
input: s,
|
||||
}
|
||||
return parser.Parse()
|
||||
}
|
||||
|
||||
// Parse loops trough parseAction() until the end of the string is reached
|
||||
func (p *actionParser) Parse() ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
for !p.eof() {
|
||||
a, err := p.parseAction()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions = append(actions, a)
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
// parseAction parses the individual sections of an action using parseSection into key/value pairs,
|
||||
// and then uses populateAction to interpret the keys/values. The function terminates
|
||||
// when EOF or ";" is reached.
|
||||
func (p *actionParser) parseAction() (*action, error) {
|
||||
a := newAction()
|
||||
section := 0
|
||||
for {
|
||||
key, value, last, err := p.parseSection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := populateAction(a, section, key, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.slurpSpaces()
|
||||
if last {
|
||||
return a, nil
|
||||
}
|
||||
section++
|
||||
}
|
||||
}
|
||||
|
||||
// populateAction is the "business logic" of the parser. It applies the key/value
|
||||
// pair to the action instance.
|
||||
func populateAction(newAction *action, section int, key, value string) error {
|
||||
// Auto-expand keys based on their index
|
||||
if key == "" && section == 0 {
|
||||
key = "action"
|
||||
} else if key == "" && section == 1 {
|
||||
key = "label"
|
||||
} else if key == "" && section == 2 && util.InStringList(actionsWithURL, newAction.Action) {
|
||||
key = "url"
|
||||
}
|
||||
|
||||
// Validate
|
||||
if key == "" {
|
||||
return fmt.Errorf("term '%s' unknown", value)
|
||||
}
|
||||
|
||||
// Populate
|
||||
if strings.HasPrefix(key, "headers.") {
|
||||
newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
|
||||
} else if strings.HasPrefix(key, "extras.") {
|
||||
newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
|
||||
} else {
|
||||
switch strings.ToLower(key) {
|
||||
case "action":
|
||||
newAction.Action = value
|
||||
case "label":
|
||||
newAction.Label = value
|
||||
case "clear":
|
||||
lvalue := strings.ToLower(value)
|
||||
if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
|
||||
return fmt.Errorf("parameter 'clear' cannot be '%s', only boolean values are allowed (true/yes/1/false/no/0)", value)
|
||||
}
|
||||
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
|
||||
case "url":
|
||||
newAction.URL = value
|
||||
case "method":
|
||||
newAction.Method = value
|
||||
case "body":
|
||||
newAction.Body = value
|
||||
default:
|
||||
return fmt.Errorf("key '%s' unknown", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSection parses a section ("key=value") and returns a key/value pair. It terminates
|
||||
// when EOF or "," is reached.
|
||||
func (p *actionParser) parseSection() (key string, value string, last bool, err error) {
|
||||
p.slurpSpaces()
|
||||
key = p.parseKey()
|
||||
r, w := p.peek()
|
||||
if isSectionEnd(r) {
|
||||
p.pos += w
|
||||
last = isLastSection(r)
|
||||
return
|
||||
} else if r == '"' || r == '\'' {
|
||||
value, last, err = p.parseQuotedValue(r)
|
||||
return
|
||||
}
|
||||
value, last = p.parseValue()
|
||||
return
|
||||
}
|
||||
|
||||
// parseKey uses a regex to determine whether the current position is a key definition ("key =")
|
||||
// and returns the key if it is, or an empty string otherwise.
|
||||
func (p *actionParser) parseKey() string {
|
||||
matches := actionsKeyRegex.FindStringSubmatch(p.input[p.pos:])
|
||||
if len(matches) == 2 {
|
||||
p.pos += len(matches[0])
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseValue reads the input until EOF, "," or ";" and returns the value string. Unlike parseQuotedValue,
|
||||
// this function does not support "," or ";" in the value itself, and spaces in the beginning and end of the
|
||||
// string are trimmed.
|
||||
func (p *actionParser) parseValue() (value string, last bool) {
|
||||
start := p.pos
|
||||
for {
|
||||
r, w := p.peek()
|
||||
if isSectionEnd(r) {
|
||||
last = isLastSection(r)
|
||||
value = strings.TrimSpace(p.input[start:p.pos])
|
||||
p.pos += w
|
||||
return
|
||||
}
|
||||
p.pos += w
|
||||
}
|
||||
}
|
||||
|
||||
// parseQuotedValue reads the input until it finds an unescaped end quote character ("), and then
|
||||
// advances the position beyond the section end. It supports quoting strings using backslash (\).
|
||||
func (p *actionParser) parseQuotedValue(quote rune) (value string, last bool, err error) {
|
||||
p.pos++
|
||||
start := p.pos
|
||||
var prev rune
|
||||
for {
|
||||
r, w := p.peek()
|
||||
if r == actionEOF {
|
||||
err = fmt.Errorf("unexpected end of input, quote started at position %d", start)
|
||||
return
|
||||
} else if r == quote && prev != '\\' {
|
||||
value = strings.ReplaceAll(p.input[start:p.pos], "\\"+string(quote), string(quote)) // \" -> "
|
||||
p.pos += w
|
||||
|
||||
// Advance until section end (after "," or ";")
|
||||
p.slurpSpaces()
|
||||
r, w := p.peek()
|
||||
last = isLastSection(r)
|
||||
if !isSectionEnd(r) {
|
||||
err = fmt.Errorf("unexpected character '%c' at position %d", r, p.pos)
|
||||
return
|
||||
}
|
||||
p.pos += w
|
||||
return
|
||||
}
|
||||
prev = r
|
||||
p.pos += w
|
||||
}
|
||||
}
|
||||
|
||||
// slurpSpaces reads all space characters and advances the position
|
||||
func (p *actionParser) slurpSpaces() {
|
||||
for {
|
||||
r, w := p.peek()
|
||||
if r == actionEOF || !isSpace(r) {
|
||||
return
|
||||
}
|
||||
p.pos += w
|
||||
}
|
||||
}
|
||||
|
||||
// peek returns the next run and its width
|
||||
func (p *actionParser) peek() (rune, int) {
|
||||
if p.eof() {
|
||||
return actionEOF, 0
|
||||
}
|
||||
return utf8.DecodeRuneInString(p.input[p.pos:])
|
||||
}
|
||||
|
||||
// eof returns true if the end of the input has been reached
|
||||
func (p *actionParser) eof() bool {
|
||||
return p.pos >= len(p.input)
|
||||
}
|
||||
|
||||
func isSpace(r rune) bool {
|
||||
return r == ' ' || r == '\t' || r == '\r' || r == '\n'
|
||||
}
|
||||
|
||||
func isSectionEnd(r rune) bool {
|
||||
return r == actionEOF || r == ';' || r == ','
|
||||
}
|
||||
|
||||
func isLastSection(r rune) bool {
|
||||
return r == actionEOF || r == ';'
|
||||
}
|
||||
176
server/actions_test.go
Normal file
176
server/actions_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseActions(t *testing.T) {
|
||||
actions, err := parseActions("[]")
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, actions)
|
||||
|
||||
// Basic test
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Open door", actions[0].Label)
|
||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||
require.Equal(t, "view", actions[1].Action)
|
||||
require.Equal(t, "Show portal", actions[1].Label)
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
// JSON
|
||||
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Open door", actions[0].Label)
|
||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||
require.Equal(t, "view", actions[1].Action)
|
||||
require.Equal(t, "Show portal", actions[1].Label)
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
// Other params
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Open door", actions[0].Label)
|
||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||
require.Equal(t, "PUT", actions[0].Method)
|
||||
require.Equal(t, "this is a body", actions[0].Body)
|
||||
|
||||
// Extras with underscores
|
||||
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "broadcast", actions[0].Action)
|
||||
require.Equal(t, "Do a thing", actions[0].Label)
|
||||
require.Equal(t, 2, len(actions[0].Extras))
|
||||
require.Equal(t, "some command", actions[0].Extras["command"])
|
||||
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
|
||||
|
||||
// Headers with dashes
|
||||
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Send request", actions[0].Label)
|
||||
require.Equal(t, 2, len(actions[0].Headers))
|
||||
require.Equal(t, "application/json", actions[0].Headers["Content-Type"])
|
||||
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
|
||||
|
||||
// Quotes
|
||||
actions, err = parseActions(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, `Look ma, "quotes"; and semicolons`, actions[0].Label)
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Single quotes
|
||||
actions, err = parseActions(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, `"quotes" and 'single quotes'`, actions[0].Label)
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Single quotes (JSON)
|
||||
actions, err = parseActions(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Post it", actions[0].Label)
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
require.Equal(t, `{"temperature": 65}`, actions[0].Body)
|
||||
|
||||
// Out of order
|
||||
actions, err = parseActions(`label="Out of order!" , action="http", url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, `Out of order!`, actions[0].Label)
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Spaces
|
||||
actions, err = parseActions(`action = http, label = 'this is a label', url = "http://google.com"`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, `this is a label`, actions[0].Label)
|
||||
require.Equal(t, `http://google.com`, actions[0].URL)
|
||||
|
||||
// Non-ASCII
|
||||
actions, err = parseActions(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, `Кохайтеся а не воюйте, 💙🫤`, actions[0].Label)
|
||||
require.Equal(t, `http://google.com`, actions[0].URL)
|
||||
|
||||
// Multiple actions, awkward spacing
|
||||
actions, err = parseActions(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org, clear=true`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, `Make love, not war 💙🫤`, actions[0].Label)
|
||||
require.Equal(t, `https://ntfy.sh`, actions[0].URL)
|
||||
require.Equal(t, false, actions[0].Clear)
|
||||
require.Equal(t, "view", actions[1].Action)
|
||||
require.Equal(t, " yo ", actions[1].Label)
|
||||
require.Equal(t, `https://x.org`, actions[1].URL)
|
||||
require.Equal(t, true, actions[1].Clear)
|
||||
|
||||
// Invalid syntax
|
||||
_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "unexpected character 'x' at position 22")
|
||||
|
||||
_, err = parseActions(`label="", action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "parameter 'label' is required")
|
||||
|
||||
_, err = parseActions(`label=, action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "parameter 'label' is required")
|
||||
|
||||
_, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
|
||||
require.EqualError(t, err, "term 'what is this anyway' unknown")
|
||||
|
||||
_, err = parseActions(`fdsfdsf`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast' and 'http'")
|
||||
|
||||
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
||||
require.EqualError(t, err, "key 'aaa' unknown")
|
||||
|
||||
_, err = parseActions(`action=http, label="omg the end quote is missing`)
|
||||
require.EqualError(t, err, "unexpected end of input, quote started at position 20")
|
||||
|
||||
_, err = parseActions(`;;;;`)
|
||||
require.EqualError(t, err, "only 3 actions allowed")
|
||||
|
||||
_, err = parseActions(`,,,,,,;;`)
|
||||
require.EqualError(t, err, "term '' unknown")
|
||||
|
||||
_, err = parseActions(`''";,;"`)
|
||||
require.EqualError(t, err, "unexpected character '\"' at position 2")
|
||||
|
||||
_, err = parseActions(`action=http, label=a label, body=somebody`)
|
||||
require.EqualError(t, err, "parameter 'url' is required for action 'http'")
|
||||
|
||||
_, err = parseActions(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)
|
||||
require.EqualError(t, err, "parameter 'body' cannot be set if method is HEAD")
|
||||
|
||||
_, err = parseActions(`[ invalid json ]`)
|
||||
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
|
||||
|
||||
_, err = parseActions(`[ { "some": "object" } ]`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast' and 'http'")
|
||||
|
||||
_, err = parseActions("\x00\x01\xFFx\xFE")
|
||||
require.EqualError(t, err, "invalid utf-8 string")
|
||||
|
||||
_, err = parseActions(`http, label, http://x.org, clear=x`)
|
||||
require.EqualError(t, err, "parameter 'clear' cannot be 'x', only boolean values are allowed (true/yes/1/false/no/0)")
|
||||
|
||||
}
|
||||
@@ -539,7 +539,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||
if actionsStr != "" {
|
||||
m.Actions, err = parseActions(actionsStr)
|
||||
if err != nil {
|
||||
return false, false, "", false, err // wrapped errHTTPBadRequestActionsInvalid
|
||||
return false, false, "", false, wrapErrHTTP(errHTTPBadRequestActionsInvalid, err.Error())
|
||||
}
|
||||
}
|
||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||
@@ -739,7 +739,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
|
||||
func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if r.Header.Get("Upgrade") != "websocket" {
|
||||
if strings.ToLower(r.Header.Get("Upgrade")) != "websocket" {
|
||||
return errHTTPBadRequestWebSocketsUpgradeHeaderMissing
|
||||
}
|
||||
if err := v.SubscriptionAllowed(); err != nil {
|
||||
|
||||
@@ -56,6 +56,13 @@ type action struct {
|
||||
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
|
||||
}
|
||||
|
||||
func newAction() *action {
|
||||
return &action{
|
||||
Headers: make(map[string]string),
|
||||
Extras: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// publishMessage is used as input when publishing as JSON
|
||||
type publishMessage struct {
|
||||
Topic string `json:"topic"`
|
||||
|
||||
107
server/util.go
107
server/util.go
@@ -1,17 +1,10 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
actionIDLength = 10
|
||||
actionsMax = 3
|
||||
)
|
||||
|
||||
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||
value := strings.ToLower(readParam(r, names...))
|
||||
if value == "" {
|
||||
@@ -47,103 +40,3 @@ func readQueryParam(r *http.Request, names ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseActions(s string) (actions []*action, err error) {
|
||||
// Parse JSON or simple format
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "[") {
|
||||
actions, err = parseActionsFromJSON(s)
|
||||
} else {
|
||||
actions, err = parseActionsFromSimple(s)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add ID field, ensure correct uppercase/lowercase
|
||||
for i := range actions {
|
||||
actions[i].ID = util.RandomString(actionIDLength)
|
||||
actions[i].Action = strings.ToLower(actions[i].Action)
|
||||
actions[i].Method = strings.ToUpper(actions[i].Method)
|
||||
}
|
||||
|
||||
// Validate
|
||||
if len(actions) > actionsMax {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "only %d actions allowed", actionsMax)
|
||||
}
|
||||
for _, action := range actions {
|
||||
if !util.InStringList([]string{"view", "broadcast", "http"}, action.Action) {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "action '%s' unknown", action.Action)
|
||||
} else if action.Label == "" {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'label' is required")
|
||||
} else if util.InStringList([]string{"view", "http"}, action.Action) && action.URL == "" {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'url' is required for action '%s'", action.Action)
|
||||
} else if action.Action == "http" && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'body' cannot be set if method is %s", action.Method)
|
||||
}
|
||||
}
|
||||
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
func parseActionsFromJSON(s string) ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
if err := json.Unmarshal([]byte(s), &actions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
func parseActionsFromSimple(s string) ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
rawActions := util.SplitNoEmpty(s, ";")
|
||||
for _, rawAction := range rawActions {
|
||||
newAction := &action{
|
||||
Headers: make(map[string]string),
|
||||
Extras: make(map[string]string),
|
||||
}
|
||||
parts := util.SplitNoEmpty(rawAction, ",")
|
||||
if len(parts) < 3 {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "action requires at least keys 'action', 'label' and one parameter: %s", rawAction)
|
||||
}
|
||||
for i, part := range parts {
|
||||
key, value := util.SplitKV(part, "=")
|
||||
if key == "" && i == 0 {
|
||||
newAction.Action = value
|
||||
} else if key == "" && i == 1 {
|
||||
newAction.Label = value
|
||||
} else if key == "" && util.InStringList([]string{"view", "http"}, newAction.Action) && i == 2 {
|
||||
newAction.URL = value
|
||||
} else if strings.HasPrefix(key, "headers.") {
|
||||
newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
|
||||
} else if strings.HasPrefix(key, "extras.") {
|
||||
newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
|
||||
} else if key != "" {
|
||||
switch strings.ToLower(key) {
|
||||
case "action":
|
||||
newAction.Action = value
|
||||
case "label":
|
||||
newAction.Label = value
|
||||
case "clear":
|
||||
lvalue := strings.ToLower(value)
|
||||
if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "'clear=%s' not allowed", value)
|
||||
}
|
||||
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
|
||||
case "url":
|
||||
newAction.URL = value
|
||||
case "method":
|
||||
newAction.Method = value
|
||||
case "body":
|
||||
newAction.Body = value
|
||||
default:
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "key '%s' unknown", key)
|
||||
}
|
||||
} else {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "unknown term '%s'", part)
|
||||
}
|
||||
}
|
||||
actions = append(actions, newAction)
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
@@ -27,56 +27,3 @@ func TestReadBoolParam(t *testing.T) {
|
||||
require.Equal(t, false, up)
|
||||
require.Equal(t, true, firebase)
|
||||
}
|
||||
|
||||
func TestParseActions(t *testing.T) {
|
||||
actions, err := parseActions("[]")
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, actions)
|
||||
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Open door", actions[0].Label)
|
||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||
require.Equal(t, "view", actions[1].Action)
|
||||
require.Equal(t, "Show portal", actions[1].Label)
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Open door", actions[0].Label)
|
||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||
require.Equal(t, "view", actions[1].Action)
|
||||
require.Equal(t, "Show portal", actions[1].Label)
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Open door", actions[0].Label)
|
||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||
require.Equal(t, "PUT", actions[0].Method)
|
||||
require.Equal(t, "this is a body", actions[0].Body)
|
||||
|
||||
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "broadcast", actions[0].Action)
|
||||
require.Equal(t, "Do a thing", actions[0].Label)
|
||||
require.Equal(t, 2, len(actions[0].Extras))
|
||||
require.Equal(t, "some command", actions[0].Extras["command"])
|
||||
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
|
||||
|
||||
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Send request", actions[0].Label)
|
||||
require.Equal(t, 2, len(actions[0].Headers))
|
||||
require.Equal(t, "application/json", actions[0].Headers["Content-Type"])
|
||||
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
|
||||
}
|
||||
|
||||
4911
web/package-lock.json
generated
4911
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,35 @@
|
||||
{
|
||||
"name": "ntfy",
|
||||
"version": "1.0.0",
|
||||
"url": "https://github.com/binwiederhier/ntfy",
|
||||
"author": "Philipp C. Heckel <philipp.heckel@gmail.com>",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"start-electron": "concurrently \"BROWSER=none npm start\" \"wait-on http://localhost:3000 && electron .\"",
|
||||
"build": "react-scripts build",
|
||||
"build-electron": "react-scripts build --em.main=build/electron.js && electron-builder",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"main": "public/electron.js",
|
||||
"homepage": "./",
|
||||
"build": {
|
||||
"appId": "io.heckel.ntfy",
|
||||
"files": [
|
||||
"build/**/*",
|
||||
"node_modules/**/*",
|
||||
"public/**/*"
|
||||
],
|
||||
"directories":{
|
||||
"buildResources": "assets"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.8.2",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
@@ -15,6 +37,7 @@
|
||||
"@mui/material": "latest",
|
||||
"dexie": "^3.2.1",
|
||||
"dexie-react-hooks": "^1.1.1",
|
||||
"electron-is-dev": "^2.0.0",
|
||||
"i18next": "^21.6.14",
|
||||
"i18next-browser-languagedetector": "^6.1.4",
|
||||
"i18next-http-backend": "^1.4.0",
|
||||
@@ -28,6 +51,12 @@
|
||||
"stacktrace-gps": "^3.0.4",
|
||||
"stacktrace-js": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^7.1.0",
|
||||
"electron": "^18.2.0",
|
||||
"electron-builder": "^23.0.3",
|
||||
"wait-on": "^6.0.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
|
||||
42
web/public/electron.js
Normal file
42
web/public/electron.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const { app, BrowserWindow, Tray, Menu, nativeImage } = require('electron');
|
||||
const isDev = require('electron-is-dev');
|
||||
const path = require('path');
|
||||
|
||||
let mainWindow;
|
||||
|
||||
const createWindow = () => {
|
||||
mainWindow = new BrowserWindow({width: 900, height: 680});
|
||||
mainWindow.loadURL(isDev ? 'http://localhost:3000' : `file://${path.join(__dirname, '../build/index.html')}`);
|
||||
mainWindow.on('closed', () => mainWindow = null);
|
||||
};
|
||||
|
||||
const createTray = () => {
|
||||
const icon = nativeImage.createFromDataURL('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAACsZJREFUWAmtWFlsXFcZ/u82++Jt7IyT2Em6ZFHTpAtWIzspEgjEUhA8VNAiIYEQUvuABBIUwUMkQIVKPCIoEiABLShISEBbhFJwIGRpIKRpbNeJ7bh2HHvssR3PPnPnLnzfmRlju6EQqUc+c++c8y/fv54z1uQOh+/7Glh0TD59TE/TND7lnfa4/64OKsM071QoeZpA/y9WWvk/B4XCC06TUC+Xyw8HTXNQ1+Ww6PpOrMebewXxvBueJ6/XHOdMJBL5J9Y97m2R0SS/wweE6JxkGx5dilWr1S/7dXsEa2o4+LyFmcFcaL5zbX3Y9gh5hpeWYpSB9XV5/H678V89BGYDXnHJlCsWn4gHrGc1K9CXxferOdvPOOKUfF8cH7nUyCtklQZXih/VNNlmirk3GdBSoIcRswW7/vVkLPYi5W2Uze8bh7J+4wLfh4dViFx5/nmrUi7/MhGNvrCkBfpeWqnW/7BUdadqntQ8zwr6vhUV34xpYnDynWvcmwQNaclDXsqgLMqkocPDw7fNx7d5qIX+/PmJxKGD6VdDkeh7ztyqOFfrokGCEWiiZ1mp0uITnuKAosaT7+pNxMYTyefutcQfbA+b1XLpH5fnF97/yD335Fu6mqTqsclDINBVmI4fDxw80KPAvJSt1MZtMcLiGxYUu83p4UkgnJZlqcl3LAj3WnTkIS9lUBYNPJjueVWgg7qocyOgliFqjZsg8gq5tRdiieQTf1gq15Y8CUbRZtyWOzZwc8lEqS3PTCtgqd13ieO68BQ2uNl64tXAewktrFuX2mPdkWAxn3sxnmx7sqUTJGqso8MGS9tbXFz8DMH8bblUX3T9QARVi8RV8qljfcJy0zRlaf6mzHEuzEtmekqCoZB4rqp0OmudHtUnlEWZlE0d1EWd1N3EozourcO65pw4eTIZQTW9VazJtbqvw9XwKVFQMsKDBuNhtp4uvGGFI+IDgKnpMjYyIis3ZsQMBIR7pONsIaMsyqRs6ohY1rPUSd3EQFDqo+kdZ3Fh4aupbdu+99uFQr2A1CBs4uEAjZjIFUMHi4dVxMXzCdCXQj4vBrwVCofl0ulTcv/DAxJJJBUPc8mpoyI2JDw7bFyT+ifTcSubyXytJ51+roWBxwG9Q73WWjZ7eSUU3//nXM0NI+x0PBGrTSgsLS9JFuFxHFrvSqIrJV279gi6tjiVspTza3JjZhY+0CQZj0mlWJSeHTslCro6eFqymCcVVN77kkGjs1p4sy2VOoSlOrFwT+XR+PjkgGaZ+ycKVbRTYUdVrmaImCvzk1dlFCEJdHRJ284+ie/ol0h7p7jFvExcvCCXzp2Rqem3pAMAiqWS6JGYhFI9Mjo6KjevXVUyKEuFHrKpY6JQ8TXT3D8+OTkAHBw6o6LCFo9ag3o4JtlCyTHEt5AxKvS6YUi5kJeZG3Py0NAxlLcJ9xti+K7Mjo/JfGZRuvv6Ze+9+yWEhDZAvzg3JyhX2d6/S7q6e+TimdOS7ElLKBZDwqvmj6rztayr1fVI1IoXi4PAcYZY1tPEEO1wEVlXgRFBDcmIXTqJsS+XyhKLJ5A/OpIVXXptWUYv/UvaenfIocEhMQ2EzHHErlXFCgQl3paU1eVl6QAY8sQTCSmVihKJx1V/ogvgIYF/pACdcMBhqONoHhF88/2d+bojyA6cRvje2IdFjoSjUSnBS8hgyS9lZOzKFdmPxO3o6gQIGzwuDn1dVSCtCKPy1pZXlATXqUsVYMLRmKo87vP4Y1ioqwCdCegmMYx3W/VPn8RrSDwwIMMbcEjkYo29JZVOy+ybI7K4eksODx1VSqvligpReSVLgySM/FI5h2q062jNyL3s7FtoAyGJIlx1225UmwJF6aJRJ3XzHXO9bWvsJa3jQFlBJkz6iuXdu32HzM7MyP0PPNgAU6ko4Qzp6b+flr8MD9OYJg9CwtzL5+T65ITs2bsP3mGxN/ZbBcOn0sk20gAkLQ+huXpFi8vkoY9AoyDjxTR1mbo6Ltt275HpN0dlNxQE40mVM8Ajjxx9VAGhAvQR1akZFCq799ADysMuQqOxh2FNmamEaz51ItGLfFD9+oUJoZkLowHoFA2mljUacqOMflKuVmHpfmnfvlMuvXZeStmMBIMhcWEdjgFJtrUjXI0KchAuAg0ilxLJNoRVBxhIBm0TjjKAuqjTqTs3CQZ6QUUMGFW7eiWMUg6w+yo8YMW7DqtqlZLkUDV2ISfd29KyDwk9MjYmMyOXxQIIKuShqo4VGFNBEgeDQYqVam5N5tEePFQgURIUBCsd1EWd1XrtDUUMLARD9bKaK5ytQ2Gb75g8WMiEP6VkfnZGevv6UF1vSBW5E0PFDAweFRvlfun8WVmamhDNrkmweQ0pwaPt6M4m8mgKTTFXqcrV0ZH1FKBg6qAu6qTuJiCV1Cp2Q0NDr9Uq5Ym+oMEDlSewsoRwrVBEaij7AJ4s7zrOpumxEdm15y6558GHJVe1Zezy6zJx6aJkpq5JFB4z6zVZmBiX1VWUP0IY4CFMYcpQdZ3xqIs6oftCE5DHKwd0q/tzOV8svdDb3nk8VnG9qmgQC0ZURz8Ur91alXgSByZ6ES9kZZTr/PR16UOCh+7dq0CWyyXJ4xqCQ0nKt9YQSlPue2gAeYZzD7yNLk0wmqAreb2WYSxAJ8Dget64wxtEBlDaqVOn/K5dB67t6+t5MhoMJuc8w8UPKiQ9CQR9JK5czhZAQxPt7TKF3OiAIisUViAD2Lg5d0P2HDgoKeRaW0enyqVwBJcO5fFG5dqa7h406qaeX8384uTZL5w9+UqxhYHFp0YLIYA9ddfu3T+4UJF6Rg+YAc9D0+RoIGP1ULhpWspr10evyK7+ftWTrk9PS/++A9KZSm26cih2mMOErem6n/ZsZwA2TM/MPHXs2LEftnSTbh0Q36mIIbx44cLvOnu3f+xUwbWLmoHTCUlF6g2jBQo/GnFrnGNqSHdvr+rIKGMW1KahwEBdzHft98aNwMr8zd8/NDDwccihc0hLi3GubRjY0Bm6H19fPvnZI4c/fHd7PJ2peXYZ+WQ26JufZELjQ6lbAQtnWre0d3apY8TFIdtAo+Qri6mupsB49lBMC+QXF0YefObZT8j0eKWlswVjEyCCOXHihPGb575VCvVuf3lvetsH9rXF0rla3cnhpoIGjgsUPhR3I4TMKYJQV1Z6WO02aEjHa5mNe3OPW3OPRHVrbXFh9Ocvv/KR1372owx1Pf3005uc35Ddgtd8rsf06IdS5777zZ+mUqmPzjm6TPpmvayZOq4LyATeCzkanmiy4qEuC/yXiO8CSMRzvLs1x9phepLNZl868sy3Pyen/5hd1/EfRvWmuvSWNeaRS/RkPDI4+NjE1NSXEoXlpaNB1zqo20abi59/vu/UfM2pie7WUDVq8l3wTwnskeZ+zTbIQ17KoCzKpGzq2KqX32/roRbh8ePHdUzl0s9/5Rv9n/7go19MxCKfCkZiu3V06wrO5gocxL7Dgd/IEobEMH6rejg+auXidL5Y/vWv/vTX53/y/e/MkGajTH7fOt4RUJOY1df4RdtY6ICFRzqTySOhUOA+3Ai3o31H1ZbnlXBruFmt2iMrudy5xx9//BzWV7nXDBGN2xpjbt/5oGUEdhtO3iD47xZOvm8a5CHvpsV38wsUaMwBWsz3rbK5xr0mzdv2t9Jv/f5vhsF4J+Q63IUAAAAASUVORK5CYII=');
|
||||
const tray = new Tray(icon);
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{ label: 'Quit' }
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
tray.setToolTip('This is my application');
|
||||
tray.setTitle('This is my title');
|
||||
}
|
||||
|
||||
app.on('ready', () => {
|
||||
createWindow();
|
||||
createTray();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.on('activate', () => {
|
||||
if (mainWindow === null) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ntfy web</title>
|
||||
<base href="/">
|
||||
|
||||
<!-- Mobile view -->
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
|
||||
@@ -150,5 +150,7 @@
|
||||
"prefs_notifications_min_priority_description_max": "Показват се известията с приоритет 5 (най-висок)",
|
||||
"prefs_notifications_delete_after_one_month_description": "Известията се премахват автоматично след един месец",
|
||||
"prefs_notifications_min_priority_description_any": "Показват се всички известия, независимо от приоритета им",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Показват се известията с приоритет {{number}} ({{name}}) или по-висок"
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Показват се известията с приоритет {{number}} ({{name}}) или по-висок",
|
||||
"notifications_actions_http_request_title": "Изпращане на HTTP {{method}} до {{url}}",
|
||||
"notifications_actions_not_supported": "Действието не се поддържа от приложението за уеб"
|
||||
}
|
||||
|
||||
156
web/public/static/langs/cs.json
Normal file
156
web/public/static/langs/cs.json
Normal file
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"action_bar_settings": "Nastavení",
|
||||
"action_bar_send_test_notification": "Odeslání testovacího oznámení",
|
||||
"action_bar_clear_notifications": "Vymazat všechna oznámení",
|
||||
"action_bar_unsubscribe": "Odhlásit odběr",
|
||||
"message_bar_type_message": "Zde napište zprávu",
|
||||
"message_bar_error_publishing": "Chyba při odesílání oznámení",
|
||||
"nav_topics_title": "Odebíraná témata",
|
||||
"nav_button_all_notifications": "Všechna oznámení",
|
||||
"nav_button_settings": "Nastavení",
|
||||
"nav_button_documentation": "Dokumentace",
|
||||
"nav_button_publish_message": "Odeslat oznámení",
|
||||
"nav_button_subscribe": "Přihlásit se k odběru tématu",
|
||||
"alert_grant_title": "Oznámení jsou zakázána",
|
||||
"alert_grant_description": "Udělte prohlížeči oprávnění k zobrazování oznámení na ploše.",
|
||||
"alert_grant_button": "Udělit nyní",
|
||||
"alert_not_supported_title": "Oznámení nejsou podporována",
|
||||
"alert_not_supported_description": "Oznámení nejsou ve vašem prohlížeči podporována.",
|
||||
"notifications_copied_to_clipboard": "Zkopírováno do schránky",
|
||||
"notifications_tags": "Značky",
|
||||
"notifications_attachment_copy_url_title": "Kopírovat URL přílohy do schránky",
|
||||
"notifications_attachment_copy_url_button": "Kopírovat URL",
|
||||
"notifications_attachment_open_title": "Přejít na {{url}}",
|
||||
"notifications_attachment_open_button": "Otevřít přílohu",
|
||||
"notifications_attachment_link_expires": "platnost odkazu končí {{date}}",
|
||||
"notifications_attachment_link_expired": "platnost odkazu ke stažení vypršela",
|
||||
"notifications_click_copy_url_title": "Kopírovat URL odkazu do schránky",
|
||||
"notifications_click_copy_url_button": "Kopírovat odkaz",
|
||||
"notifications_click_open_button": "Otevřít odkaz",
|
||||
"notifications_none_for_topic_title": "K tomuto tématu jste zatím neobdrželi žádné oznámení.",
|
||||
"notifications_none_for_topic_description": "Pro odeslání oznámení k tomuto tématu, odešlete PUT nebo POST požadavek na URL tématu.",
|
||||
"notifications_example": "Příklad",
|
||||
"publish_dialog_base_url_placeholder": "URL služby, např. https://example.com",
|
||||
"publish_dialog_topic_label": "Název tématu",
|
||||
"publish_dialog_topic_placeholder": "Název tématu, např. phil_alerts",
|
||||
"publish_dialog_priority_default": "Výchozí priorita",
|
||||
"publish_dialog_priority_high": "Vysoká priorita",
|
||||
"publish_dialog_priority_max": "Nevyšší priorita",
|
||||
"publish_dialog_base_url_label": "URL služby",
|
||||
"prefs_users_dialog_password_label": "Heslo",
|
||||
"prefs_users_dialog_title_add": "Přidat uživatele",
|
||||
"prefs_users_dialog_title_edit": "Upravit uživatele",
|
||||
"prefs_users_dialog_base_url_label": "URL služby, např. https://ntfy.sh",
|
||||
"prefs_users_dialog_username_label": "Uživatelské jméno, např. phil",
|
||||
"notifications_actions_open_url_title": "Přejít na {{url}}",
|
||||
"notifications_none_for_any_title": "Neobdrželi jste žádná oznámení.",
|
||||
"notifications_none_for_any_description": "Pro odeslání oznámení k tématu stačí na URL tématu odeslat PUT nebo POST požadavek. Zde je příklad s použitím jednoho z vašich témat.",
|
||||
"notifications_no_subscriptions_description": "Kliknutím na \"{{linktext}}\" vytvoříte téma nebo se k němu přihlásíte. Poté můžete odesílat zprávy prostřednictvím PUT nebo POST požadavků a zde budete dostávat oznámení.",
|
||||
"notifications_more_details": "Další informace naleznete na <websiteLink>webových stránkách</websiteLink> nebo v <docsLink>dokumentaci</docsLink>.",
|
||||
"publish_dialog_title_topic": "Odeslat do {{téma}}",
|
||||
"publish_dialog_title_no_topic": "Odeslat oznámení",
|
||||
"publish_dialog_progress_uploading": "Nahrávání …",
|
||||
"publish_dialog_message_published": "Oznámení odesláno",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "překračuje {{fileSizeLimit}} limit souboru a kvótu, {{remainingBytes}} zbývá",
|
||||
"publish_dialog_attachment_limits_quota_reached": "překračuje kvótu, {{remainingBytes}} zbývá",
|
||||
"publish_dialog_priority_min": "Nejnižší priorita",
|
||||
"publish_dialog_title_label": "Název",
|
||||
"publish_dialog_title_placeholder": "Název oznámení, např. Upozornění na volné místo na disku",
|
||||
"publish_dialog_message_placeholder": "Zde napište zprávu",
|
||||
"publish_dialog_tags_label": "Značky",
|
||||
"publish_dialog_priority_label": "Priorita",
|
||||
"publish_dialog_click_label": "Klikněte na URL",
|
||||
"publish_dialog_click_placeholder": "Adresa URL, která se otevře po kliknutí na oznámení",
|
||||
"publish_dialog_email_label": "E-mail",
|
||||
"publish_dialog_email_placeholder": "Adresa pro odeslání oznámení, např. phil@example.com",
|
||||
"publish_dialog_attach_label": "URL přílohy",
|
||||
"publish_dialog_filename_label": "Název souboru",
|
||||
"publish_dialog_filename_placeholder": "Název souboru přílohy",
|
||||
"publish_dialog_delay_label": "Zpoždění",
|
||||
"publish_dialog_delay_placeholder": "Zpožděné doručení, např. {{unixTimestamp}}, {{relativeTime}} nebo \"{{naturalLanguage}}\". (pouze v angličtině)",
|
||||
"publish_dialog_chip_click_label": "Klikněte na URL",
|
||||
"publish_dialog_chip_email_label": "Přeposlat na e-mail",
|
||||
"publish_dialog_chip_delay_label": "Zpožděné doručení",
|
||||
"publish_dialog_chip_topic_label": "Změnit téma",
|
||||
"publish_dialog_details_examples_description": "Příklady a podrobný popis všech funkcí odesílání naleznete v <docsLink>dokumentaci</docsLink>.",
|
||||
"publish_dialog_chip_attach_url_label": "Připojit soubor pomocí URL",
|
||||
"publish_dialog_chip_attach_file_label": "Připojit místní soubor",
|
||||
"publish_dialog_button_send": "Odeslat",
|
||||
"publish_dialog_checkbox_publish_another": "Odeslat další",
|
||||
"publish_dialog_attached_file_title": "Přiložený soubor:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Název souboru přílohy",
|
||||
"publish_dialog_drop_file_here": "Přetáhněte soubor sem",
|
||||
"emoji_picker_search_placeholder": "Hledat emodži",
|
||||
"subscribe_dialog_subscribe_title": "Přihlásit odběr tématu",
|
||||
"subscribe_dialog_subscribe_description": "Témata nemusí být chráněna heslem, proto zvolte název, který není snadné uhodnout. Jakmile se přihlásíte k odběru, můžete odesílat oznámení pomocí PUT/POST požadavků.",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Název tématu, např. phil_alerts",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Použít jiný server",
|
||||
"subscribe_dialog_login_title": "Vyžadováno přihlášení",
|
||||
"subscribe_dialog_login_description": "Toto téma je chráněno heslem. Pro přihlášení k odběru zadejte prosím uživatelské jméno a heslo.",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Zrušit",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr",
|
||||
"subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil",
|
||||
"subscribe_dialog_login_password_label": "Heslo",
|
||||
"subscribe_dialog_login_button_back": "Zpět",
|
||||
"subscribe_dialog_login_button_login": "Přihlásit se",
|
||||
"subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován",
|
||||
"subscribe_dialog_error_user_anonymous": "anonymně",
|
||||
"prefs_notifications_title": "Oznámení",
|
||||
"prefs_notifications_sound_description_some": "Oznámení přehrají při doručení zvuk {{sound}}",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Zobrazit oznámení, pokud je priorita {{number}} ({{name}}) nebo vyšší",
|
||||
"prefs_notifications_min_priority_description_max": "Zobrazit oznámení, pokud je priorita 5 (nejvyšší)",
|
||||
"prefs_notifications_min_priority_any": "Jakákoli priorita",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Nízká priorita a vyšší",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Výchozí priorita a vyšší",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Vysoká priorita a vyšší",
|
||||
"prefs_notifications_delete_after_three_hours": "Po třech hodinách",
|
||||
"prefs_notifications_delete_after_one_day": "Po jednom dni",
|
||||
"prefs_notifications_delete_after_one_week": "Po jednom týdnu",
|
||||
"prefs_notifications_delete_after_one_month": "Po jednom měsíci",
|
||||
"prefs_notifications_delete_after_never_description": "Oznámení nejsou nikdy automaticky mazána",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Oznámení se automaticky odstraní po třech hodinách",
|
||||
"prefs_notifications_delete_after_one_day_description": "Oznámení se automaticky odstraní po jednom dni",
|
||||
"prefs_notifications_delete_after_one_week_description": "Oznámení se automaticky odstraní po jednom týdnu",
|
||||
"prefs_notifications_delete_after_one_month_description": "Oznámení se automaticky odstraní po jednom měsíci",
|
||||
"prefs_users_title": "Správa uživatelů",
|
||||
"prefs_users_add_button": "Přidat uživatele",
|
||||
"prefs_users_table_user_header": "Uživatel",
|
||||
"prefs_users_table_base_url_header": "URL služby",
|
||||
"prefs_users_dialog_button_cancel": "Zrušit",
|
||||
"prefs_users_dialog_button_add": "Přidat",
|
||||
"prefs_users_dialog_button_save": "Uložit",
|
||||
"priority_min": "nejnižší",
|
||||
"priority_low": "nízká",
|
||||
"priority_default": "výchozí",
|
||||
"priority_high": "vysoká",
|
||||
"priority_max": "nejvyšší",
|
||||
"error_boundary_title": "Ale ne, ntfy havaroval",
|
||||
"error_boundary_description": "K tomu by samozřejmě nemělo docházet. Velmi se za to omlouváme.<br/>Pokud máte chvilku, nahlaste to prosím <githubLink>na GitHubu</githubLink> nebo nám dejte vědět prostřednictvím <discordLink>Discordu</discordLink> nebo <matrixLink>Matrixu</matrixLink>.",
|
||||
"error_boundary_button_copy_stack_trace": "Kopírovat výpis zásobníku",
|
||||
"error_boundary_stack_trace": "Výpis zásobníku",
|
||||
"publish_dialog_tags_placeholder": "Seznam značek oddělených čárkou, např. warning, srv1-backup",
|
||||
"notifications_actions_not_supported": "Akce není podporována ve webové aplikaci",
|
||||
"notifications_actions_http_request_title": "Odeslat HTTP {{metoda}} na {{url}}",
|
||||
"notifications_no_subscriptions_title": "Vypadá to, že ještě nemáte žádné odběry.",
|
||||
"prefs_notifications_min_priority_description_any": "Zobrazit všechna oznámení bez ohledu na prioritu",
|
||||
"publish_dialog_priority_low": "Nízká priorita",
|
||||
"publish_dialog_message_label": "Zpráva",
|
||||
"publish_dialog_button_cancel": "Zrušit",
|
||||
"notifications_loading": "Načítání oznámení …",
|
||||
"publish_dialog_progress_uploading_detail": "Nahrávání {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_attachment_limits_file_reached": "překračuje {{fileSizeLimit}} limit souboru",
|
||||
"publish_dialog_attach_placeholder": "Připojit soubor pomocí URL, např. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_button_cancel_sending": "Zrušit odesílání",
|
||||
"publish_dialog_other_features": "Další funkce:",
|
||||
"prefs_notifications_sound_title": "Zvuk oznámení",
|
||||
"prefs_notifications_min_priority_max_only": "Pouze nejvyšší priorita",
|
||||
"prefs_notifications_min_priority_title": "Nejnižší priorita",
|
||||
"prefs_notifications_delete_after_title": "Odstranit oznámení",
|
||||
"prefs_notifications_delete_after_never": "Nikdy",
|
||||
"prefs_notifications_sound_no_sound": "Žádný zvuk",
|
||||
"prefs_notifications_sound_description_none": "Oznámení nepřehrají při doručení žádný zvuk",
|
||||
"prefs_users_description": "Zde můžete přidávat/odebírat uživatele pro chráněná témata. Upozorňujeme, že uživatelské jméno a heslo jsou uloženy v místním úložišti prohlížeče.",
|
||||
"error_boundary_gathering_info": "Získejte více informací …",
|
||||
"prefs_appearance_language_title": "Jazyk",
|
||||
"prefs_appearance_title": "Vzhled"
|
||||
}
|
||||
@@ -150,5 +150,7 @@
|
||||
"prefs_notifications_delete_after_three_hours_description": "Benachrichtigungen werden nach drei Stunden automatisch gelöscht",
|
||||
"prefs_notifications_delete_after_one_day_description": "Benachrichtigungen werden nach einem Tag automatisch gelöscht",
|
||||
"prefs_notifications_delete_after_one_week_description": "Benachrichtigungen werden nach einer Woche automatisch gelöscht",
|
||||
"priority_min": "min"
|
||||
"priority_min": "min",
|
||||
"notifications_actions_not_supported": "Diese Aktion wird in der Web-App nicht unterstützt",
|
||||
"notifications_actions_http_request_title": "Sende HTTP {{method}} an {{url}}"
|
||||
}
|
||||
|
||||
@@ -1,29 +1,48 @@
|
||||
{
|
||||
"action_bar_show_menu": "Show menu",
|
||||
"action_bar_logo_alt": "ntfy logo",
|
||||
"action_bar_settings": "Settings",
|
||||
"action_bar_send_test_notification": "Send test notification",
|
||||
"action_bar_clear_notifications": "Clear all notifications",
|
||||
"action_bar_unsubscribe": "Unsubscribe",
|
||||
"action_bar_toggle_mute": "Mute/unmute notifications",
|
||||
"action_bar_toggle_action_menu": "Open/close action menu",
|
||||
"message_bar_type_message": "Type a message here",
|
||||
"message_bar_error_publishing": "Error publishing notification",
|
||||
"message_bar_show_dialog": "Show publish dialog",
|
||||
"message_bar_publish": "Publish message",
|
||||
"nav_topics_title": "Subscribed topics",
|
||||
"nav_button_all_notifications": "All notifications",
|
||||
"nav_button_settings": "Settings",
|
||||
"nav_button_documentation": "Documentation",
|
||||
"nav_button_publish_message": "Publish notification",
|
||||
"nav_button_subscribe": "Subscribe to topic",
|
||||
"nav_button_muted": "Notifications muted",
|
||||
"nav_button_connecting": "connecting",
|
||||
"alert_grant_title": "Notifications are disabled",
|
||||
"alert_grant_description": "Grant your browser permission to display desktop notifications.",
|
||||
"alert_grant_button": "Grant now",
|
||||
"alert_not_supported_title": "Notifications not supported",
|
||||
"alert_not_supported_description": "Notifications are not supported in your browser.",
|
||||
"notifications_list": "Notifications list",
|
||||
"notifications_list_item": "Notification",
|
||||
"notifications_delete": "Delete notification",
|
||||
"notifications_copied_to_clipboard": "Copied to clipboard",
|
||||
"notifications_tags": "Tags",
|
||||
"notifications_priority_x": "Priority {{priority}}",
|
||||
"notifications_new_indicator": "New notification",
|
||||
"notifications_attachment_image": "Attachment image",
|
||||
"notifications_attachment_copy_url_title": "Copy attachment URL to clipboard",
|
||||
"notifications_attachment_copy_url_button": "Copy URL",
|
||||
"notifications_attachment_open_title": "Go to {{url}}",
|
||||
"notifications_attachment_open_button": "Open attachment",
|
||||
"notifications_attachment_link_expires": "link expires {{date}}",
|
||||
"notifications_attachment_link_expired": "download link expired",
|
||||
"notifications_attachment_file_image": "image file",
|
||||
"notifications_attachment_file_video": "video file",
|
||||
"notifications_attachment_file_audio": "audio file",
|
||||
"notifications_attachment_file_app": "Android app file",
|
||||
"notifications_attachment_file_document": "other document",
|
||||
"notifications_click_copy_url_title": "Copy link URL to clipboard",
|
||||
"notifications_click_copy_url_button": "Copy link",
|
||||
"notifications_click_open_button": "Open link",
|
||||
@@ -47,6 +66,7 @@
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "exceeds {{fileSizeLimit}} file limit and quota, {{remainingBytes}} remaining",
|
||||
"publish_dialog_attachment_limits_file_reached": "exceeds {{fileSizeLimit}} file limit",
|
||||
"publish_dialog_attachment_limits_quota_reached": "exceeds quota, {{remainingBytes}} remaining",
|
||||
"publish_dialog_emoji_picker_show": "Pick emoji",
|
||||
"publish_dialog_priority_min": "Min. priority",
|
||||
"publish_dialog_priority_low": "Low priority",
|
||||
"publish_dialog_priority_default": "Default priority",
|
||||
@@ -56,6 +76,7 @@
|
||||
"publish_dialog_base_url_placeholder": "Service URL, e.g. https://example.com",
|
||||
"publish_dialog_topic_label": "Topic name",
|
||||
"publish_dialog_topic_placeholder": "Topic name, e.g. phil_alerts",
|
||||
"publish_dialog_topic_reset": "Reset topic",
|
||||
"publish_dialog_title_label": "Title",
|
||||
"publish_dialog_title_placeholder": "Notification title, e.g. Disk space alert",
|
||||
"publish_dialog_message_label": "Message",
|
||||
@@ -65,14 +86,18 @@
|
||||
"publish_dialog_priority_label": "Priority",
|
||||
"publish_dialog_click_label": "Click URL",
|
||||
"publish_dialog_click_placeholder": "URL that is opened when notification is clicked",
|
||||
"publish_dialog_click_reset": "Remove click URL",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
|
||||
"publish_dialog_email_reset": "Remove email forward",
|
||||
"publish_dialog_attach_label": "Attachment URL",
|
||||
"publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_attach_reset": "Remove attachment URL",
|
||||
"publish_dialog_filename_label": "Filename",
|
||||
"publish_dialog_filename_placeholder": "Attachment filename",
|
||||
"publish_dialog_delay_label": "Delay",
|
||||
"publish_dialog_delay_placeholder": "Delay delivery, e.g. {{unixTimestamp}}, {{relativeTime}}, or \"{{naturalLanguage}}\" (English only)",
|
||||
"publish_dialog_delay_reset": "Remove delayed delivery",
|
||||
"publish_dialog_other_features": "Other features:",
|
||||
"publish_dialog_chip_click_label": "Click URL",
|
||||
"publish_dialog_chip_email_label": "Forward to email",
|
||||
@@ -87,12 +112,15 @@
|
||||
"publish_dialog_checkbox_publish_another": "Publish another",
|
||||
"publish_dialog_attached_file_title": "Attached file:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Attachment filename",
|
||||
"publish_dialog_attached_file_remove": "Remove attached file",
|
||||
"publish_dialog_drop_file_here": "Drop file here",
|
||||
"emoji_picker_search_placeholder": "Search emoji",
|
||||
"emoji_picker_search_clear": "Clear search",
|
||||
"subscribe_dialog_subscribe_title": "Subscribe to topic",
|
||||
"subscribe_dialog_subscribe_description": "Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications.",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Topic name, e.g. phil_alerts",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Use another server",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Service URL",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Cancel",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Subscribe",
|
||||
"subscribe_dialog_login_title": "Login required",
|
||||
@@ -108,6 +136,7 @@
|
||||
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",
|
||||
"prefs_notifications_sound_description_some": "Notifications play the {{sound}} sound when they arrive",
|
||||
"prefs_notifications_sound_no_sound": "No sound",
|
||||
"prefs_notifications_sound_play": "Play selected sound",
|
||||
"prefs_notifications_min_priority_title": "Minimum priority",
|
||||
"prefs_notifications_min_priority_description_any": "Showing all notifications, regardless of priority",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Show notifications if priority is {{number}} ({{name}}) or above",
|
||||
@@ -130,7 +159,10 @@
|
||||
"prefs_notifications_delete_after_one_month_description": "Notifications are auto-deleted after one month",
|
||||
"prefs_users_title": "Manage users",
|
||||
"prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.",
|
||||
"prefs_users_table": "Users table",
|
||||
"prefs_users_add_button": "Add user",
|
||||
"prefs_users_edit_button": "Edit user",
|
||||
"prefs_users_delete_button": "Delete user",
|
||||
"prefs_users_table_user_header": "User",
|
||||
"prefs_users_table_base_url_header": "Service URL",
|
||||
"prefs_users_dialog_title_add": "Add user",
|
||||
@@ -152,5 +184,7 @@
|
||||
"error_boundary_description": "This should obviously not happen. Very sorry about this.<br/>If you have a minute, please <githubLink>report this on GitHub</githubLink>, or let us know via <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_button_copy_stack_trace": "Copy stack trace",
|
||||
"error_boundary_stack_trace": "Stack trace",
|
||||
"error_boundary_gathering_info": "Gather more info …"
|
||||
"error_boundary_gathering_info": "Gather more info …",
|
||||
"error_boundary_unsupported_indexeddb_title": "Private browsing not supported",
|
||||
"error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.<br/><br/>While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it <githubLink>in this GitHub issue</githubLink>, or talk to us on <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>."
|
||||
}
|
||||
|
||||
@@ -150,5 +150,7 @@
|
||||
"priority_default": "predeterminada",
|
||||
"prefs_notifications_delete_after_one_day_description": "Las notificaciones se eliminan automáticamente después de un día",
|
||||
"prefs_notifications_delete_after_one_week_description": "Las notificaciones se eliminan automáticamente después de una semana",
|
||||
"priority_low": "baja"
|
||||
"priority_low": "baja",
|
||||
"notifications_actions_not_supported": "Acción no soportada en la aplicación web",
|
||||
"notifications_actions_http_request_title": "Enviar HTTP {{method}} a {{url}}"
|
||||
}
|
||||
|
||||
@@ -28,11 +28,11 @@
|
||||
"notifications_example": "Exemple",
|
||||
"notifications_loading": "Chargement des notifications…",
|
||||
"publish_dialog_progress_uploading": "Téléversement…",
|
||||
"publish_dialog_priority_min": "Priorité min.",
|
||||
"publish_dialog_priority_min": "Priorité minimum",
|
||||
"publish_dialog_priority_low": "Basse priorité",
|
||||
"publish_dialog_priority_default": "Priorité par défaut",
|
||||
"publish_dialog_base_url_label": "URL du serveur",
|
||||
"publish_dialog_base_url_placeholder": "URL du serveur, par ex. https://exemple.com",
|
||||
"publish_dialog_base_url_label": "URL du service",
|
||||
"publish_dialog_base_url_placeholder": "URL du service, par ex. https://exemple.com",
|
||||
"publish_dialog_title_label": "Titre",
|
||||
"publish_dialog_message_label": "Message",
|
||||
"publish_dialog_topic_label": "Nom du sujet",
|
||||
@@ -46,11 +46,111 @@
|
||||
"publish_dialog_message_published": "Notification publiée",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "dépasse la limite et le quota du fichier {{fileSizeLimit}}, {{remainingBytes}} restant",
|
||||
"publish_dialog_priority_high": "Haute priorité",
|
||||
"publish_dialog_priority_max": "Priorité max.",
|
||||
"publish_dialog_priority_max": "Priorité maximum",
|
||||
"publish_dialog_attachment_limits_file_reached": "Dépasse la limite du fichier {{fileSizeLimit}}",
|
||||
"nav_button_subscribe": "S'abonner au sujet",
|
||||
"notifications_no_subscriptions_description": "Cliquez sur le lien « Ajouter un abonnement » pour créer ou vous abonner à un sujet. Après cela, vous pouvez envoyer des messages via PUT ou POST et vous recevrez des notifications ici.",
|
||||
"notifications_no_subscriptions_description": "Cliquez sur le lien « {{linktext}} » pour créer ou vous abonner à un sujet. Après cela, vous pouvez envoyer des messages via PUT ou POST et vous recevrez des notifications ici.",
|
||||
"alert_grant_title": "Les notifications sont désactivées",
|
||||
"alert_grant_description": "Autorisez votre navigateur à afficher les notifications du bureau.",
|
||||
"alert_grant_button": "Accorder maintenant"
|
||||
"alert_grant_button": "Accorder maintenant",
|
||||
"notifications_none_for_any_title": "Vous n'avez reçu aucune notification.",
|
||||
"publish_dialog_title_topic": "Publier vers {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Publier la notification",
|
||||
"notifications_more_details": "Pour plus d'information, visitez <websiteLink>le site web</websiteLink> ou <docsLink>la documentation</docsLink>.",
|
||||
"publish_dialog_title_placeholder": "Titre de la notification, par ex. Alerte d'espace disque",
|
||||
"publish_dialog_topic_placeholder": "Nom du sujet, par ex. phil_alerts",
|
||||
"publish_dialog_delay_placeholder": "Délai de la délivrance, par ex. {{unixTimestamp}}, {{relativeTime}}, ou « {{naturalLanguage}} » (en anglais seulement)",
|
||||
"publish_dialog_other_features": "Autres fonctionnalités :",
|
||||
"notifications_actions_not_supported": "Cette action n'est pas supportée dans l'application web",
|
||||
"notifications_actions_http_request_title": "Envoyer une requête HTTP {{method}} à {{url}}",
|
||||
"publish_dialog_attachment_limits_quota_reached": "quota dépassé, {{remainingBytes}} restants",
|
||||
"publish_dialog_tags_placeholder": "Liste séparée par des virgules d'étiquettes, par ex. avertissement,backup-srv1",
|
||||
"publish_dialog_priority_label": "Priorité",
|
||||
"publish_dialog_click_label": "Cliquer sur l'URL",
|
||||
"publish_dialog_click_placeholder": "URL ouverte quand la notification est cliquée",
|
||||
"publish_dialog_attach_label": "URL de la pièce jointe",
|
||||
"publish_dialog_attach_placeholder": "Attachez un fichier par une URL, par ex. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_label": "Nom du fichier",
|
||||
"notifications_none_for_topic_description": "Pour envoyer des notifications à ce sujet, faites simplement une requête PUT ou POST à l'URL du sujet.",
|
||||
"notifications_none_for_any_description": "Pour envoyer des notifications à un sujet, faites simplement une requête PUT ou POST à l'URL du sujet. Voici un exemple utilisant un de vos sujets.",
|
||||
"publish_dialog_filename_placeholder": "Nom du fichier joint",
|
||||
"publish_dialog_delay_label": "Délai",
|
||||
"publish_dialog_chip_click_label": "Cliquez sur l'URL",
|
||||
"subscribe_dialog_subscribe_title": "S'abonner au sujet",
|
||||
"subscribe_dialog_login_title": "Connexion nécessaire",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Priorité basse et au-dessus",
|
||||
"prefs_users_dialog_button_cancel": "Annuler",
|
||||
"error_boundary_button_copy_stack_trace": "Copier la stack strace",
|
||||
"publish_dialog_attached_file_title": "Fichier joint :",
|
||||
"publish_dialog_checkbox_publish_another": "Publier un autre",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Nom du fichier joint",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Utiliser un autre serveur",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Annuler",
|
||||
"prefs_notifications_sound_description_none": "Les notifications ne font aucun son quand elles arrivent",
|
||||
"prefs_notifications_sound_description_some": "Les notifications jouent le son {{sound}} quand elles arrivent",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Montrer les notifications si leur priorité est {{number}} ({{name}}) ou plus",
|
||||
"publish_dialog_button_cancel": "Annuler",
|
||||
"publish_dialog_button_send": "Envoyer",
|
||||
"publish_dialog_drop_file_here": "Déposez un fichier ici",
|
||||
"emoji_picker_search_placeholder": "Chercher un émoji",
|
||||
"subscribe_dialog_subscribe_description": "Le sujet n'est peut-être pas protégé par un mot de passe, choisissez un nom de sujet difficile à deviner. Une fois abonné, vous pouvez PUT/POST des notifications.",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Nom de sujet, par ex. alertes_de_phil",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "S'abonner",
|
||||
"subscribe_dialog_login_description": "Ce sujet est protégé par un mot de passe. Veuillez entrer le nom d'utilisateur et le mot de passe pour vous abonner.",
|
||||
"subscribe_dialog_login_username_label": "Nom d'utilisateur, par ex. phil",
|
||||
"subscribe_dialog_login_button_login": "Connexion",
|
||||
"prefs_notifications_sound_title": "Son de notification",
|
||||
"prefs_notifications_delete_after_never": "Jamais",
|
||||
"prefs_users_table_base_url_header": "URL de service",
|
||||
"subscribe_dialog_login_password_label": "Mot de passe",
|
||||
"prefs_notifications_title": "Notifications",
|
||||
"prefs_notifications_delete_after_title": "Supprimer les notifications",
|
||||
"prefs_users_add_button": "Ajouter un utilisateur",
|
||||
"subscribe_dialog_login_button_back": "Retour",
|
||||
"subscribe_dialog_error_user_anonymous": "anonyme",
|
||||
"prefs_notifications_sound_no_sound": "Aucun son",
|
||||
"prefs_notifications_min_priority_title": "Priorité minimum",
|
||||
"prefs_notifications_min_priority_description_any": "Montrer toutes les notifications, quelque soit leur priorité",
|
||||
"prefs_notifications_min_priority_description_max": "Montrer les notifications si la priorité est 5 (max)",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Priorité par défaut et au-dessus",
|
||||
"prefs_notifications_min_priority_max_only": "Seulement la priorité maximale",
|
||||
"prefs_notifications_delete_after_three_hours": "Après trois heures",
|
||||
"prefs_notifications_delete_after_one_day": "Après un jour",
|
||||
"subscribe_dialog_error_user_not_authorized": "L'utilisateur {{username}} n'est pas autorisé",
|
||||
"prefs_notifications_min_priority_any": "N'importe quelle priorité",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Priorité haute et au-dessus",
|
||||
"prefs_users_dialog_base_url_label": "URL du service, par ex. https://ntfy.sh",
|
||||
"prefs_notifications_delete_after_one_week_description": "Les notifications sont supprimées automatiquement après une semaine",
|
||||
"prefs_users_dialog_username_label": "Nom d'utilisateur, par ex. phil",
|
||||
"prefs_users_dialog_password_label": "Mot de passe",
|
||||
"prefs_notifications_delete_after_one_month_description": "Les notifications sont supprimées automatiquement après un mois",
|
||||
"prefs_users_title": "Gérer les utilisateurs",
|
||||
"prefs_users_description": "Ajoutez/supprimez des utilisateurs pour vos sujets protégés ici. Notez que cet utilisateur et ce mot de passe sont stockés dans le stockage local du navigateur.",
|
||||
"prefs_users_table_user_header": "Utilisateur",
|
||||
"prefs_users_dialog_title_edit": "Éditer l'utilisateur",
|
||||
"prefs_users_dialog_button_add": "Ajouter",
|
||||
"error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.<br/>Si vous avez une minute, merci de <githubLink>signaler ceci sur GitHub</githubLink>, ou faites-le nous savoir par <discordLink>Discord</discordLink> ou <matrixLink>Matric</matrixLink>.",
|
||||
"prefs_users_dialog_title_add": "Ajouter un utilisateur",
|
||||
"error_boundary_stack_trace": "Stack trace",
|
||||
"error_boundary_gathering_info": "Récupérer plus d'information…",
|
||||
"prefs_notifications_delete_after_one_week": "Après une semaine",
|
||||
"prefs_notifications_delete_after_one_month": "Après un mois",
|
||||
"prefs_notifications_delete_after_never_description": "Les notifications ne sont jamais supprimées automatiquement",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Les notifications sont supprimées automatiquement après trois heures",
|
||||
"prefs_notifications_delete_after_one_day_description": "Les notifications sont supprimées automatiquement après un jour",
|
||||
"prefs_appearance_title": "Apparence",
|
||||
"prefs_appearance_language_title": "Langue",
|
||||
"priority_min": "min",
|
||||
"priority_low": "basse",
|
||||
"priority_default": "défault",
|
||||
"priority_high": "haute",
|
||||
"priority_max": "max",
|
||||
"error_boundary_title": "Oh non, ntfy a planté",
|
||||
"publish_dialog_chip_attach_url_label": "Joindre un fichier par URL",
|
||||
"publish_dialog_chip_attach_file_label": "Joindre un fichier local",
|
||||
"publish_dialog_chip_delay_label": "Délayer l'envoi",
|
||||
"publish_dialog_chip_topic_label": "Changer de sujet",
|
||||
"publish_dialog_details_examples_description": "Pour des exemples et une description détaillée des fonctionnalités d'envoi, voir la <docsLink>documentation</docsLink>.",
|
||||
"publish_dialog_button_cancel_sending": "Annuler l'envoi",
|
||||
"prefs_users_dialog_button_save": "Enregistrer"
|
||||
}
|
||||
|
||||
@@ -150,5 +150,7 @@
|
||||
"prefs_notifications_delete_after_never_description": "Notifikasi tidak pernah dihapus secara otomatis",
|
||||
"prefs_notifications_delete_after_one_day_description": "Notifikasi dihapus secara otomatis setelah satu hari",
|
||||
"priority_default": "bawaan",
|
||||
"priority_min": "min"
|
||||
"priority_min": "min",
|
||||
"notifications_actions_not_supported": "Tindakan tidak didukung di aplikasi web",
|
||||
"notifications_actions_http_request_title": "Kirim {{method}} HTTP ke {{url}}"
|
||||
}
|
||||
|
||||
1
web/public/static/langs/nl.json
Normal file
1
web/public/static/langs/nl.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -150,5 +150,7 @@
|
||||
"priority_default": "öntanımlı",
|
||||
"prefs_notifications_min_priority_description_max": "Öncelik 5 (en fazla) ise bildirimleri göster",
|
||||
"prefs_notifications_delete_after_never_description": "Bildirimler asla otomatik olarak silinmez",
|
||||
"priority_high": "yüksek"
|
||||
"priority_high": "yüksek",
|
||||
"notifications_actions_not_supported": "Eylem, web uygulamasında desteklenmiyor",
|
||||
"notifications_actions_http_request_title": "{{url}} adresine HTTP {{method}} gönder"
|
||||
}
|
||||
|
||||
@@ -44,16 +44,22 @@ const ActionBar = (props) => {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
edge="start"
|
||||
aria-label={t("action_bar_show_menu")}
|
||||
onClick={props.onMobileDrawerToggle}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Box component="img" src={logo} sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
marginRight: '10px',
|
||||
height: '28px'
|
||||
}}/>
|
||||
<Box
|
||||
component="img"
|
||||
src={logo}
|
||||
alt={t("action_bar_logo_alt")}
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
marginRight: '10px',
|
||||
height: '28px'
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
@@ -173,10 +179,10 @@ const SettingsIcons = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} sx={{marginRight: 0}}>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} sx={{marginRight: 0}} aria-label={t("action_bar_toggle_mute")}>
|
||||
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
|
||||
</IconButton>
|
||||
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen}>
|
||||
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}>
|
||||
<MoreVertIcon/>
|
||||
</IconButton>
|
||||
<Popper
|
||||
|
||||
@@ -5,27 +5,36 @@ import fileImage from "../img/file-image.svg";
|
||||
import fileVideo from "../img/file-video.svg";
|
||||
import fileAudio from "../img/file-audio.svg";
|
||||
import fileApp from "../img/file-app.svg";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
const AttachmentIcon = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const type = props.type;
|
||||
let imageFile;
|
||||
let imageFile, imageLabel;
|
||||
if (!type) {
|
||||
imageFile = fileDocument;
|
||||
imageLabel = t("notifications_attachment_file_image");
|
||||
} else if (type.startsWith('image/')) {
|
||||
imageFile = fileImage;
|
||||
imageLabel = t("notifications_attachment_file_video");
|
||||
} else if (type.startsWith('video/')) {
|
||||
imageFile = fileVideo;
|
||||
imageLabel = t("notifications_attachment_file_video");
|
||||
} else if (type.startsWith('audio/')) {
|
||||
imageFile = fileAudio;
|
||||
imageLabel = t("notifications_attachment_file_audio");
|
||||
} else if (type === "application/vnd.android.package-archive") {
|
||||
imageFile = fileApp;
|
||||
imageLabel = t("notifications_attachment_file_app");
|
||||
} else {
|
||||
imageFile = fileDocument;
|
||||
imageLabel = t("notifications_attachment_file_document");
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src={imageFile}
|
||||
alt={imageLabel}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: '28px',
|
||||
|
||||
@@ -12,11 +12,15 @@ const DialogFooter = (props) => {
|
||||
paddingLeft: '24px',
|
||||
paddingBottom: '8px',
|
||||
}}>
|
||||
<DialogContentText component="div" sx={{
|
||||
margin: '0px',
|
||||
paddingTop: '12px',
|
||||
paddingBottom: '4px'
|
||||
}}>
|
||||
<DialogContentText
|
||||
component="div"
|
||||
aria-live="polite"
|
||||
sx={{
|
||||
margin: '0px',
|
||||
paddingTop: '12px',
|
||||
paddingBottom: '4px'
|
||||
}}
|
||||
>
|
||||
{props.status}
|
||||
</DialogContentText>
|
||||
<DialogActions sx={{paddingRight: 2}}>
|
||||
|
||||
@@ -80,10 +80,16 @@ const EmojiPicker = (props) => {
|
||||
variant="standard"
|
||||
fullWidth
|
||||
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
|
||||
inputProps={{
|
||||
role: "searchbox",
|
||||
"aria-label": t("emoji_picker_search_placeholder")
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment:
|
||||
<InputAdornment position="end" sx={{ display: (search) ? '' : 'none' }}>
|
||||
<IconButton size="small" onClick={handleSearchClear} edge="end"><Close/></IconButton>
|
||||
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}>
|
||||
<Close/>
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}}
|
||||
/>
|
||||
@@ -130,10 +136,12 @@ const Category = (props) => {
|
||||
const Emoji = (props) => {
|
||||
const emoji = props.emoji;
|
||||
const matches = emojiMatches(emoji, props.search);
|
||||
const title = `${emoji.description} (${emoji.aliases[0]})`;
|
||||
return (
|
||||
<EmojiDiv
|
||||
onClick={props.onClick}
|
||||
title={`${emoji.description} (${emoji.aliases[0]})`}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
style={{ display: (matches) ? '' : 'none' }}
|
||||
>
|
||||
{props.emoji.emoji}
|
||||
|
||||
@@ -10,13 +10,28 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
this.state = {
|
||||
error: false,
|
||||
originalStack: null,
|
||||
niceStack: null
|
||||
niceStack: null,
|
||||
unsupportedIndexedDB: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
console.error("[ErrorBoundary] Error caught", error, info);
|
||||
|
||||
// Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see
|
||||
// - https://github.com/dexie/Dexie.js/issues/312
|
||||
// - https://bugzilla.mozilla.org/show_bug.cgi?id=781982
|
||||
const isUnsupportedIndexedDB = error?.name === "InvalidStateError" ||
|
||||
(error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1);
|
||||
|
||||
if (isUnsupportedIndexedDB) {
|
||||
this.handleUnsupportedIndexedDB();
|
||||
} else {
|
||||
this.handleError(error, info);
|
||||
}
|
||||
}
|
||||
|
||||
handleError(error, info) {
|
||||
// Immediately render original stack trace
|
||||
const prettierOriginalStack = info.componentStack
|
||||
.trim()
|
||||
@@ -36,6 +51,13 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
handleUnsupportedIndexedDB() {
|
||||
this.setState({
|
||||
error: true,
|
||||
unsupportedIndexedDB: true
|
||||
});
|
||||
}
|
||||
|
||||
copyStack() {
|
||||
let stack = "";
|
||||
if (this.state.niceStack) {
|
||||
@@ -46,34 +68,61 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{margin: '20px'}}>
|
||||
<h2>{t("error_boundary_title")} 😮</h2>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="error_boundary_description"
|
||||
components={{
|
||||
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues"/>,
|
||||
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
|
||||
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Button variant="outlined" onClick={() => this.copyStack()}>{t("error_boundary_button_copy_stack_trace")}</Button>
|
||||
</p>
|
||||
<h3>{t("error_boundary_stack_trace")}</h3>
|
||||
{this.state.niceStack
|
||||
? <pre>{this.state.niceStack}</pre>
|
||||
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> {t("error_boundary_gathering_info")}</>}
|
||||
<pre>{this.state.originalStack}</pre>
|
||||
</div>
|
||||
);
|
||||
if (this.state.unsupportedIndexedDB) {
|
||||
return this.renderUnsupportedIndexedDB();
|
||||
} else {
|
||||
return this.renderError();
|
||||
}
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
renderUnsupportedIndexedDB() {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<div style={{margin: '20px'}}>
|
||||
<h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2>
|
||||
<p style={{maxWidth: "600px"}}>
|
||||
<Trans
|
||||
i18nKey="error_boundary_unsupported_indexeddb_description"
|
||||
components={{
|
||||
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues/208"/>,
|
||||
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
|
||||
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderError() {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<div style={{margin: '20px'}}>
|
||||
<h2>{t("error_boundary_title")} 😮</h2>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="error_boundary_description"
|
||||
components={{
|
||||
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues"/>,
|
||||
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
|
||||
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Button variant="outlined" onClick={() => this.copyStack()}>{t("error_boundary_button_copy_stack_trace")}</Button>
|
||||
</p>
|
||||
<h3>{t("error_boundary_stack_trace")}</h3>
|
||||
{this.state.niceStack
|
||||
? <pre>{this.state.niceStack}</pre>
|
||||
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> {t("error_boundary_gathering_info")}</>}
|
||||
<pre>{this.state.originalStack}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t
|
||||
|
||||
@@ -75,13 +75,15 @@ const MessageBar = (props) => {
|
||||
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
|
||||
}}
|
||||
>
|
||||
<IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick}>
|
||||
<IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick} aria-label={t("message_bar_show_dialog")}>
|
||||
<KeyboardArrowUpIcon/>
|
||||
</IconButton>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
placeholder={t("message_bar_type_message")}
|
||||
aria-label={t("message_bar_type_message")}
|
||||
role="textbox"
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
@@ -94,7 +96,7 @@ const MessageBar = (props) => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick}>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick} aria-label={t("message_bar_publish")}>
|
||||
<SendIcon/>
|
||||
</IconButton>
|
||||
<Portal>
|
||||
|
||||
@@ -31,10 +31,15 @@ const navWidth = 280;
|
||||
const Navigation = (props) => {
|
||||
const navigationList = <NavList {...props}/>;
|
||||
return (
|
||||
<Box component="nav" sx={{width: {sm: Navigation.width}, flexShrink: {sm: 0}}}>
|
||||
<Box
|
||||
component="nav"
|
||||
role="navigation"
|
||||
sx={{width: {sm: Navigation.width}, flexShrink: {sm: 0}}}
|
||||
>
|
||||
{/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
role="menubar"
|
||||
open={props.mobileDrawerOpen}
|
||||
onClose={props.onMobileDrawerToggle}
|
||||
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
|
||||
@@ -49,6 +54,7 @@ const Navigation = (props) => {
|
||||
<Drawer
|
||||
open
|
||||
variant="permanent"
|
||||
role="menubar"
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
|
||||
@@ -157,6 +163,7 @@ const SubscriptionList = (props) => {
|
||||
}
|
||||
|
||||
const SubscriptionItem = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const subscription = props.subscription;
|
||||
const iconBadge = (subscription.new <= 99) ? subscription.new : "99+";
|
||||
@@ -166,16 +173,19 @@ const SubscriptionItem = (props) => {
|
||||
const label = (subscription.baseUrl === window.location.origin)
|
||||
? subscription.topic
|
||||
: topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||
const ariaLabel = (subscription.state === ConnectionState.Connecting)
|
||||
? `${label} (${t("nav_button_connecting")})`
|
||||
: label;
|
||||
const handleClick = async () => {
|
||||
navigate(routes.forSubscription(subscription));
|
||||
await subscriptionManager.markNotificationsRead(subscription.id);
|
||||
};
|
||||
return (
|
||||
<ListItemButton onClick={handleClick} selected={props.selected}>
|
||||
<ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
|
||||
<ListItemIcon>{icon}</ListItemIcon>
|
||||
<ListItemText primary={label}/>
|
||||
{subscription.mutedUntil > 0 &&
|
||||
<ListItemIcon edge="end"><NotificationsOffOutlined /></ListItemIcon>}
|
||||
<ListItemIcon edge="end" aria-label={t("nav_button_muted")}><NotificationsOffOutlined /></ListItemIcon>}
|
||||
</ListItemButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -98,6 +98,8 @@ const NotificationList = (props) => {
|
||||
>
|
||||
<Container
|
||||
maxWidth="md"
|
||||
role="list"
|
||||
aria-label={t("notifications_list")}
|
||||
sx={{
|
||||
marginTop: 3,
|
||||
marginBottom: (props.messageBar) ? "100px" : 3 // Hack to avoid hiding notifications behind the message bar
|
||||
@@ -143,9 +145,9 @@ const NotificationItem = (props) => {
|
||||
const hasUserActions = notification.actions && notification.actions.length > 0;
|
||||
const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
|
||||
return (
|
||||
<Card sx={{ minWidth: 275, padding: 1 }}>
|
||||
<Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
|
||||
<CardContent>
|
||||
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }}>
|
||||
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography sx={{ fontSize: 14 }} color="text.secondary">
|
||||
@@ -153,15 +155,15 @@ const NotificationItem = (props) => {
|
||||
{[1,2,4,5].includes(notification.priority) &&
|
||||
<img
|
||||
src={priorityFiles[notification.priority]}
|
||||
alt={`Priority ${notification.priority}`}
|
||||
alt={t("notifications_priority_x", { priority: notification.priority})}
|
||||
style={{ verticalAlign: 'bottom' }}
|
||||
/>}
|
||||
{notification.new === 1 &&
|
||||
<svg style={{ width: '8px', height: '8px', marginLeft: '4px' }} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg style={{ width: '8px', height: '8px', marginLeft: '4px' }} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-label={t("notifications_new_indicator")}>
|
||||
<circle cx="50" cy="50" r="50" fill="#338574"/>
|
||||
</svg>}
|
||||
</Typography>
|
||||
{notification.title && <Typography variant="h5" component="div">{formatTitle(notification)}</Typography>}
|
||||
{notification.title && <Typography variant="h5" component="div" role="rowheader">{formatTitle(notification)}</Typography>}
|
||||
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>
|
||||
{autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
|
||||
</Typography>
|
||||
@@ -289,6 +291,7 @@ const Attachment = (props) => {
|
||||
};
|
||||
|
||||
const Image = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
@@ -296,6 +299,7 @@ const Image = (props) => {
|
||||
component="img"
|
||||
src={props.attachment.url}
|
||||
loading="lazy"
|
||||
alt={t("notifications_attachment_image")}
|
||||
onClick={() => setOpen(true)}
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
@@ -316,6 +320,7 @@ const Image = (props) => {
|
||||
<Box
|
||||
component="img"
|
||||
src={props.attachment.url}
|
||||
alt={t("notifications_attachment_image")}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
maxWidth: 1,
|
||||
@@ -347,13 +352,16 @@ const UserAction = (props) => {
|
||||
if (action.action === "broadcast") {
|
||||
return (
|
||||
<Tooltip title={t("notifications_actions_not_supported")}>
|
||||
<span><Button disabled>{action.label}</Button></span>
|
||||
<span><Button disabled aria-label={t("notifications_actions_not_supported")}>{action.label}</Button></span>
|
||||
</Tooltip>
|
||||
);
|
||||
} else if (action.action === "view") {
|
||||
return (
|
||||
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
|
||||
<Button onClick={() => openUrl(action.url)}>{action.label}</Button>
|
||||
<Button
|
||||
onClick={() => openUrl(action.url)}
|
||||
aria-label={t("notifications_actions_open_url_title", { url: action.url })}
|
||||
>{action.label}</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
} else if (action.action === "http") {
|
||||
@@ -361,7 +369,10 @@ const UserAction = (props) => {
|
||||
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
|
||||
return (
|
||||
<Tooltip title={t("notifications_actions_http_request_title", { method: method, url: action.url })}>
|
||||
<Button onClick={() => performHttpAction(notification, action)}>{label}</Button>
|
||||
<Button
|
||||
onClick={() => performHttpAction(notification, action)}
|
||||
aria-label={t("notifications_actions_http_request_title", { method: method, url: action.url })}
|
||||
>{label}</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -416,7 +427,7 @@ const NoNotifications = (props) => {
|
||||
return (
|
||||
<VerticallyCenteredContainer maxWidth="xs">
|
||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||
<img src={logoOutline} height="64" width="64"/><br />
|
||||
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
|
||||
{t("notifications_none_for_topic_title")}
|
||||
</Typography>
|
||||
<Paragraph>
|
||||
@@ -442,7 +453,7 @@ const NoNotificationsWithoutSubscription = (props) => {
|
||||
return (
|
||||
<VerticallyCenteredContainer maxWidth="xs">
|
||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||
<img src={logoOutline} height="64" width="64"/><br />
|
||||
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
|
||||
{t("notifications_none_for_any_title")}
|
||||
</Typography>
|
||||
<Paragraph>
|
||||
@@ -466,7 +477,7 @@ const NoSubscriptions = () => {
|
||||
return (
|
||||
<VerticallyCenteredContainer maxWidth="xs">
|
||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||
<img src={logoOutline} height="64" width="64"/><br />
|
||||
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
|
||||
{t("notifications_no_subscriptions_title")}
|
||||
</Typography>
|
||||
<Paragraph>
|
||||
|
||||
@@ -34,11 +34,6 @@ import DialogActions from "@mui/material/DialogActions";
|
||||
import userManager from "../app/UserManager";
|
||||
import {playSound, shuffle, sounds, validUrl} from "../app/utils";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import priority1 from "../img/priority-1.svg";
|
||||
import priority2 from "../img/priority-2.svg";
|
||||
import priority3 from "../img/priority-3.svg";
|
||||
import priority4 from "../img/priority-4.svg";
|
||||
import priority5 from "../img/priority-5.svg";
|
||||
|
||||
const Preferences = () => {
|
||||
return (
|
||||
@@ -55,7 +50,7 @@ const Preferences = () => {
|
||||
const Notifications = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card sx={{p: 3}}>
|
||||
<Card sx={{p: 3}} aria-label={t("prefs_notifications_title")}>
|
||||
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||
{t("prefs_notifications_title")}
|
||||
</Typography>
|
||||
@@ -70,6 +65,7 @@ const Notifications = () => {
|
||||
|
||||
const Sound = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefSound";
|
||||
const sound = useLiveQuery(async () => prefs.sound());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setSound(ev.target.value);
|
||||
@@ -84,15 +80,15 @@ const Sound = () => {
|
||||
description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label });
|
||||
}
|
||||
return (
|
||||
<Pref title={t("prefs_notifications_sound_title")} description={description}>
|
||||
<Pref labelId={labelId} title={t("prefs_notifications_sound_title")} description={description}>
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
|
||||
<Select value={sound} onChange={handleChange}>
|
||||
<Select value={sound} onChange={handleChange} aria-labelledby={labelId}>
|
||||
<MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem>
|
||||
{Object.entries(sounds).map(s => <MenuItem key={s[0]} value={s[0]}>{s[1].label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<IconButton onClick={() => playSound(sound)} disabled={sound === "none"}>
|
||||
<IconButton onClick={() => playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}>
|
||||
<PlayArrowIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
@@ -102,6 +98,7 @@ const Sound = () => {
|
||||
|
||||
const MinPriority = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefMinPriority";
|
||||
const minPriority = useLiveQuery(async () => prefs.minPriority());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setMinPriority(ev.target.value);
|
||||
@@ -128,9 +125,9 @@ const MinPriority = () => {
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Pref title={t("prefs_notifications_min_priority_title")} description={description}>
|
||||
<Pref labelId={labelId} title={t("prefs_notifications_min_priority_title")} description={description}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={minPriority} onChange={handleChange}>
|
||||
<Select value={minPriority} onChange={handleChange} aria-labelledby={labelId}>
|
||||
<MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem>
|
||||
<MenuItem value={2}>{t("prefs_notifications_min_priority_low_and_higher")}</MenuItem>
|
||||
<MenuItem value={3}>{t("prefs_notifications_min_priority_default_and_higher")}</MenuItem>
|
||||
@@ -144,6 +141,7 @@ const MinPriority = () => {
|
||||
|
||||
const DeleteAfter = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefDeleteAfter";
|
||||
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setDeleteAfter(ev.target.value);
|
||||
@@ -161,9 +159,9 @@ const DeleteAfter = () => {
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<Pref title={t("prefs_notifications_delete_after_title")} description={description}>
|
||||
<Pref labelId={labelId} title={t("prefs_notifications_delete_after_title")} description={description}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={deleteAfter} onChange={handleChange}>
|
||||
<Select value={deleteAfter} onChange={handleChange} aria-labelledby={labelId}>
|
||||
<MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem>
|
||||
<MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem>
|
||||
<MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem>
|
||||
@@ -177,7 +175,7 @@ const DeleteAfter = () => {
|
||||
|
||||
const PrefGroup = (props) => {
|
||||
return (
|
||||
<div>
|
||||
<div role="table">
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
@@ -185,28 +183,39 @@ const PrefGroup = (props) => {
|
||||
|
||||
const Pref = (props) => {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
marginTop: "10px",
|
||||
marginBottom: "20px",
|
||||
}}>
|
||||
<div style={{
|
||||
flex: '1 0 40%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
paddingRight: '30px'
|
||||
}}>
|
||||
<div
|
||||
role="row"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
marginTop: "10px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="cell"
|
||||
id={props.labelId}
|
||||
aria-label={props.title}
|
||||
style={{
|
||||
flex: '1 0 40%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
paddingRight: '30px'
|
||||
}}
|
||||
>
|
||||
<div><b>{props.title}</b></div>
|
||||
{props.description && <div><em>{props.description}</em></div>}
|
||||
</div>
|
||||
<div style={{
|
||||
flex: '1 0 calc(60% - 50px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div
|
||||
role="cell"
|
||||
style={{
|
||||
flex: '1 0 calc(60% - 50px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,7 +244,7 @@ const Users = () => {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Card sx={{ padding: 1 }}>
|
||||
<Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
|
||||
<CardContent sx={{ paddingBottom: 1 }}>
|
||||
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||
{t("prefs_users_title")}
|
||||
@@ -291,7 +300,7 @@ const UserTable = (props) => {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Table size="small">
|
||||
<Table size="small" aria-label={t("prefs_users_table")}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell>
|
||||
@@ -305,13 +314,13 @@ const UserTable = (props) => {
|
||||
key={user.baseUrl}
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
<TableCell component="th" scope="row" sx={{paddingLeft: 0}}>{user.username}</TableCell>
|
||||
<TableCell>{user.baseUrl}</TableCell>
|
||||
<TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
|
||||
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton onClick={() => handleEditClick(user)}>
|
||||
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
|
||||
<EditIcon/>
|
||||
</IconButton>
|
||||
<IconButton onClick={() => handleDeleteClick(user)}>
|
||||
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
@@ -371,6 +380,7 @@ const UserDialog = (props) => {
|
||||
margin="dense"
|
||||
id="baseUrl"
|
||||
label={t("prefs_users_dialog_base_url_label")}
|
||||
aria-label={t("prefs_users_dialog_base_url_label")}
|
||||
value={baseUrl}
|
||||
onChange={ev => setBaseUrl(ev.target.value)}
|
||||
type="url"
|
||||
@@ -382,6 +392,7 @@ const UserDialog = (props) => {
|
||||
margin="dense"
|
||||
id="username"
|
||||
label={t("prefs_users_dialog_username_label")}
|
||||
aria-label={t("prefs_users_dialog_username_label")}
|
||||
value={username}
|
||||
onChange={ev => setUsername(ev.target.value)}
|
||||
type="text"
|
||||
@@ -392,6 +403,7 @@ const UserDialog = (props) => {
|
||||
margin="dense"
|
||||
id="password"
|
||||
label={t("prefs_users_dialog_password_label")}
|
||||
aria-label={t("prefs_users_dialog_password_label")}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={ev => setPassword(ev.target.value)}
|
||||
@@ -410,7 +422,7 @@ const UserDialog = (props) => {
|
||||
const Appearance = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card sx={{p: 3}}>
|
||||
<Card sx={{p: 3}} aria-label={t("prefs_appearance_title")}>
|
||||
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||
{t("prefs_appearance_title")}
|
||||
</Typography>
|
||||
@@ -423,21 +435,25 @@ const Appearance = () => {
|
||||
|
||||
const Language = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇧🇬", "🇩🇪", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
|
||||
const labelId = "prefLanguage";
|
||||
const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
|
||||
const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
|
||||
const lang = i18n.language ?? "en";
|
||||
|
||||
// Remember: Flags are not languages. Don't put flags next to the language in the list.
|
||||
// Languages names from: https://www.omniglot.com/language/names.htm
|
||||
// Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
|
||||
|
||||
return (
|
||||
<Pref title={title}>
|
||||
<Pref labelId={labelId} title={title}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={i18n.language} onChange={(ev) => i18n.changeLanguage(ev.target.value)}>
|
||||
<Select value={lang} onChange={(ev) => i18n.changeLanguage(ev.target.value)} aria-labelledby={labelId}>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="es">Español</MenuItem>
|
||||
<MenuItem value="bg">Български</MenuItem>
|
||||
<MenuItem value="cs">Čeština</MenuItem>
|
||||
<MenuItem value="de">Deutsch</MenuItem>
|
||||
<MenuItem value="es">Español</MenuItem>
|
||||
<MenuItem value="fr">Français</MenuItem>
|
||||
<MenuItem value="id">Bahasa Indonesia</MenuItem>
|
||||
<MenuItem value="ja">日本語</MenuItem>
|
||||
<MenuItem value="nb_NO">Norsk bokmål</MenuItem>
|
||||
|
||||
@@ -232,7 +232,7 @@ const PublishDialog = (props) => {
|
||||
<DialogContent>
|
||||
{dropZone && <DropBox/>}
|
||||
{showTopicUrl &&
|
||||
<ClosableRow closable={!!props.baseUrl && !!props.topic} disabled={disabled} onClose={() => {
|
||||
<ClosableRow closable={!!props.baseUrl && !!props.topic} disabled={disabled} closeLabel={t("publish_dialog_topic_reset")} onClose={() => {
|
||||
setBaseUrl(props.baseUrl);
|
||||
setTopic(props.topic);
|
||||
setShowTopicUrl(false);
|
||||
@@ -247,6 +247,9 @@ const PublishDialog = (props) => {
|
||||
type="url"
|
||||
variant="standard"
|
||||
sx={{flexGrow: 1, marginRight: 1}}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_base_url_label")
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
@@ -259,6 +262,9 @@ const PublishDialog = (props) => {
|
||||
variant="standard"
|
||||
autoFocus={!messageFocused}
|
||||
sx={{flexGrow: 1}}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_topic_label")
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
@@ -272,6 +278,9 @@ const PublishDialog = (props) => {
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_title_label")
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
@@ -286,6 +295,9 @@ const PublishDialog = (props) => {
|
||||
autoFocus={messageFocused}
|
||||
fullWidth
|
||||
multiline
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_message_label")
|
||||
}}
|
||||
/>
|
||||
<div style={{display: 'flex'}}>
|
||||
<EmojiPicker
|
||||
@@ -293,7 +305,7 @@ const PublishDialog = (props) => {
|
||||
onEmojiPick={handleEmojiPick}
|
||||
onClose={handleEmojiClose}
|
||||
/>
|
||||
<DialogIconButton disabled={disabled} onClick={handleEmojiClick}>
|
||||
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
|
||||
<InsertEmoticonIcon/>
|
||||
</DialogIconButton>
|
||||
<TextField
|
||||
@@ -306,6 +318,9 @@ const PublishDialog = (props) => {
|
||||
type="text"
|
||||
variant="standard"
|
||||
sx={{flexGrow: 1, marginRight: 1}}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_tags_label")
|
||||
}}
|
||||
/>
|
||||
<FormControl
|
||||
variant="standard"
|
||||
@@ -319,11 +334,14 @@ const PublishDialog = (props) => {
|
||||
value={priority}
|
||||
onChange={(ev) => setPriority(ev.target.value)}
|
||||
disabled={disabled}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_priority_label")
|
||||
}}
|
||||
>
|
||||
{[5,4,3,2,1].map(priority =>
|
||||
<MenuItem key={`priorityMenuItem${priority}`} value={priority}>
|
||||
<MenuItem key={`priorityMenuItem${priority}`} value={priority} aria-label={t("notifications_priority_x", { priority: priority })}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img src={priorities[priority].file} style={{marginRight: "8px"}}/>
|
||||
<img src={priorities[priority].file} style={{marginRight: "8px"}} alt={t("notifications_priority_x", { priority: priority })}/>
|
||||
<div>{priorities[priority].label}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
@@ -332,7 +350,7 @@ const PublishDialog = (props) => {
|
||||
</FormControl>
|
||||
</div>
|
||||
{showClickUrl &&
|
||||
<ClosableRow disabled={disabled} onClose={() => {
|
||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_click_reset")} onClose={() => {
|
||||
setClickUrl("");
|
||||
setShowClickUrl(false);
|
||||
}}>
|
||||
@@ -346,11 +364,14 @@ const PublishDialog = (props) => {
|
||||
type="url"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_click_label")
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
{showEmail &&
|
||||
<ClosableRow disabled={disabled} onClose={() => {
|
||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_email_reset")} onClose={() => {
|
||||
setEmail("");
|
||||
setShowEmail(false);
|
||||
}}>
|
||||
@@ -364,11 +385,14 @@ const PublishDialog = (props) => {
|
||||
type="email"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_email_label")
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
{showAttachUrl &&
|
||||
<ClosableRow disabled={disabled} onClose={() => {
|
||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
|
||||
setAttachUrl("");
|
||||
setFilename("");
|
||||
setFilenameEdited(false);
|
||||
@@ -398,6 +422,9 @@ const PublishDialog = (props) => {
|
||||
type="url"
|
||||
variant="standard"
|
||||
sx={{flexGrow: 5, marginRight: 1}}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_attach_label")
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
@@ -412,6 +439,9 @@ const PublishDialog = (props) => {
|
||||
type="text"
|
||||
variant="standard"
|
||||
sx={{flexGrow: 1}}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_filename_label")
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
@@ -420,6 +450,7 @@ const PublishDialog = (props) => {
|
||||
ref={attachFileInput}
|
||||
onChange={handleAttachFileChanged}
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
{showAttachFile && <AttachmentBox
|
||||
file={attachFile}
|
||||
@@ -434,7 +465,7 @@ const PublishDialog = (props) => {
|
||||
}}
|
||||
/>}
|
||||
{showDelay &&
|
||||
<ClosableRow disabled={disabled} onClose={() => {
|
||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_delay_reset")} onClose={() => {
|
||||
setDelay("");
|
||||
setShowDelay(false);
|
||||
}}>
|
||||
@@ -452,6 +483,9 @@ const PublishDialog = (props) => {
|
||||
type="text"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_delay_label")
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
@@ -459,12 +493,12 @@ const PublishDialog = (props) => {
|
||||
{t("publish_dialog_other_features")}
|
||||
</Typography>
|
||||
<div>
|
||||
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showTopicUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_topic_label")} onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showTopicUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_topic_label")} aria-label={t("publish_dialog_chip_topic_label")} onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
</div>
|
||||
<Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}>
|
||||
<Trans
|
||||
@@ -483,7 +517,13 @@ const PublishDialog = (props) => {
|
||||
label={t("publish_dialog_checkbox_publish_another")}
|
||||
sx={{marginRight: 2}}
|
||||
control={
|
||||
<Checkbox size="small" checked={publishAnother} onChange={(ev) => setPublishAnother(ev.target.checked)} />
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={publishAnother}
|
||||
onChange={(ev) => setPublishAnother(ev.target.checked)}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_checkbox_publish_another")
|
||||
}} />
|
||||
} />
|
||||
<Button onClick={props.onClose}>{t("publish_dialog_button_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>{t("publish_dialog_button_send")}</Button>
|
||||
@@ -497,7 +537,7 @@ const PublishDialog = (props) => {
|
||||
|
||||
const Row = (props) => {
|
||||
return (
|
||||
<div style={{display: 'flex'}}>
|
||||
<div style={{display: 'flex'}} role="row">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
@@ -508,7 +548,11 @@ const ClosableRow = (props) => {
|
||||
return (
|
||||
<Row>
|
||||
{props.children}
|
||||
{closable && <DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>}
|
||||
{closable &&
|
||||
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={props.closeLabel}>
|
||||
<Close/>
|
||||
</DialogIconButton>
|
||||
}
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
@@ -523,6 +567,7 @@ const DialogIconButton = (props) => {
|
||||
sx={{height: "45px", marginTop: "17px", ...sx}}
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
aria-label={props["aria-label"]}
|
||||
>
|
||||
{props.children}
|
||||
</IconButton>
|
||||
@@ -557,13 +602,15 @@ const AttachmentBox = (props) => {
|
||||
<Typography variant="body2" sx={{ color: 'text.primary' }}>
|
||||
{formatBytes(file.size)}
|
||||
{props.error &&
|
||||
<Typography component="span" sx={{ color: 'error.main' }}>
|
||||
<Typography component="span" sx={{ color: 'error.main' }} aria-live="polite">
|
||||
{" "}({props.error})
|
||||
</Typography>
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>
|
||||
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={t("publish_dialog_attached_file_remove")}>
|
||||
<Close/>
|
||||
</DialogIconButton>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
@@ -588,6 +635,7 @@ const ExpandingTextField = (props) => {
|
||||
ref={invisibleFieldRef}
|
||||
component="span"
|
||||
variant={props.variant}
|
||||
aria-hidden={true}
|
||||
sx={{position: "absolute", left: "-200%"}}
|
||||
>
|
||||
{props.value}
|
||||
@@ -601,7 +649,10 @@ const ExpandingTextField = (props) => {
|
||||
variant="standard"
|
||||
sx={{ width: `${textWidth}px`, borderBottom: "none" }}
|
||||
InputProps={{ style: { fontSize: theme.typography[props.variant].fontSize } }}
|
||||
inputProps={{ style: { paddingBottom: 0, paddingTop: 0 } }}
|
||||
inputProps={{
|
||||
style: { paddingBottom: 0, paddingTop: 0 },
|
||||
"aria-label": props.placeholder
|
||||
}}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -102,16 +102,26 @@ const SubscribePage = (props) => {
|
||||
margin="dense"
|
||||
id="topic"
|
||||
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
|
||||
inputProps={{ maxLength: 64 }}
|
||||
value={props.topic}
|
||||
onChange={ev => props.setTopic(ev.target.value)}
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
maxLength: 64,
|
||||
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder")
|
||||
}}
|
||||
/>
|
||||
<FormControlLabel
|
||||
sx={{pt: 1}}
|
||||
control={<Checkbox onChange={handleUseAnotherChanged}/>}
|
||||
control={
|
||||
<Checkbox
|
||||
onChange={handleUseAnotherChanged}
|
||||
inputProps={{
|
||||
"aria-label": t("subscribe_dialog_subscribe_use_another_label")
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={t("subscribe_dialog_subscribe_use_another_label")} />
|
||||
{anotherServerVisible && <Autocomplete
|
||||
freeSolo
|
||||
@@ -120,7 +130,12 @@ const SubscribePage = (props) => {
|
||||
inputValue={props.baseUrl}
|
||||
onInputChange={(ev, newVal) => props.setBaseUrl(newVal)}
|
||||
renderInput={ (params) =>
|
||||
<TextField {...params} placeholder={window.location.origin} variant="standard"/>
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={window.location.origin}
|
||||
variant="standard"
|
||||
aria-label={t("subscribe_dialog_subscribe_base_url_label")}
|
||||
/>
|
||||
}
|
||||
/>}
|
||||
</DialogContent>
|
||||
@@ -168,6 +183,9 @@ const LoginPage = (props) => {
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
"aria-label": t("subscribe_dialog_login_username_label")
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
@@ -178,6 +196,9 @@ const LoginPage = (props) => {
|
||||
onChange={ev => setPassword(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
"aria-label": t("subscribe_dialog_login_password_label")
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter status={errorText}>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user