Compare commits

..

52 Commits

Author SHA1 Message Date
Philipp Heckel
3baa1a9fd0 Electron WIP, nothing works... 2022-05-05 20:49:48 -04:00
Philipp Heckel
c1517e259d Fix rendering for publish example 2022-05-05 10:53:51 -04:00
Philipp Heckel
66d30fb42a Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-05-05 10:26:34 -04:00
Philipp C. Heckel
ea30132763 Merge pull request #234 from aTable/main
Full feature message example
2022-05-05 10:26:15 -04:00
aTable
1c237435ec Update publish.md 2022-05-05 21:17:28 +10:00
aTable
c37793d06f Update publish.md 2022-05-05 21:13:45 +10:00
aTable
57c7a353b5 Update publish.md 2022-05-05 21:10:14 +10:00
aTable
8154705f0b Update publish.md 2022-05-05 20:57:52 +10:00
aTable
fd2c6ef590 Update publish.md 2022-05-05 20:49:53 +10:00
aTable
6ece25e7f3 Add files via upload 2022-05-05 20:15:52 +10:00
aTable
a2ef6180bb Delete complete-notification.png 2022-05-05 20:14:29 +10:00
aTable
c3c4c9e9aa Update publish.md 2022-05-05 20:14:01 +10:00
Philipp Heckel
031c848984 Improved caddy configuration 2022-05-04 15:45:20 -04:00
Ruben
1f70ff1b06 Added translation using Weblate (Dutch) 2022-05-04 16:34:39 +02:00
aTable
1af9a85847 Update publish.md 2022-05-04 23:33:11 +10:00
aTable
e474d1e8b0 Delete multiline-notification.png 2022-05-04 23:29:00 +10:00
aTable
4b117a790a Add files via upload 2022-05-04 23:27:50 +10:00
aTable
72a2a8c82e Update publish.md 2022-05-04 23:24:38 +10:00
aTable
a7af16beb1 Update publish.md 2022-05-04 21:29:22 +10:00
aTable
7d38bc7654 Add files via upload 2022-05-04 21:24:20 +10:00
aTable
bcbcbf12ac Update publish.md 2022-05-04 21:23:17 +10:00
Philipp Heckel
8b5d2a8ca0 Changelog 2022-05-03 19:40:27 -04:00
Philipp Heckel
9b8e637618 Changelog 2022-05-03 18:01:50 -04:00
Philipp Heckel
15a45d9eb7 More labels, and live regions 2022-05-03 15:09:20 -04:00
Philipp Heckel
8a7bc38861 Finish up the labelling 2022-05-03 14:53:07 -04:00
Philipp Heckel
2d96560375 Finish publish dialog aria- stuff 2022-05-02 20:02:21 -04:00
Philipp Heckel
bb5e0e3fed WIP: Accessibility of web app 2022-05-02 19:30:29 -04:00
Philipp Heckel
4a8678bf39 Changelog 2022-05-01 20:26:41 -04:00
Philipp Heckel
ed28082c01 Added French 2022-04-30 20:16:17 -04:00
Philipp Heckel
0d3dcfdc7a Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-30 20:12:47 -04:00
Nathanaël Houn
672203467d Translated using Weblate (French)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2022-05-01 02:12:40 +02:00
Philipp Heckel
4ce619f9cb Add error message specifically for private browsing mode, closes #208 2022-04-29 20:51:26 -04:00
Philipp Heckel
5344337b43 Add Czech as language 2022-04-29 20:12:12 -04:00
Philipp Heckel
cf3238859c Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-29 20:03:00 -04:00
Philipp Heckel
9a03a9e81b Made web app sounds quieter 2022-04-29 19:51:02 -04:00
Philipp Heckel
edfed24c27 Make Upgrade header check for websockets case insensitive, closes #228 2022-04-29 13:23:04 -04:00
waclaw66
7118dcc124 Translated using Weblate (Czech)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/cs/
2022-04-28 15:13:43 +02:00
Linerly
5bcb35f756 Translated using Weblate (Indonesian)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2022-04-28 15:13:41 +02:00
Nathanaël Houn
eaf3c42227 Translated using Weblate (French)
Currently translated at 49.3% (76 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2022-04-28 15:13:41 +02:00
Rogelio Dominguez
16a4feaeb6 Translated using Weblate (Spanish)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2022-04-28 15:13:40 +02:00
109247019824
b60458318c Translated using Weblate (Bulgarian)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2022-04-28 15:13:40 +02:00
Oğuz Ersen
b10c88afd7 Translated using Weblate (Turkish)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/
2022-04-28 15:13:40 +02:00
Christian Meis
f0cae0fbac Translated using Weblate (German)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2022-04-28 15:13:39 +02:00
Philipp Heckel
28bb8d4446 More actions tests 2022-04-27 14:28:58 -04:00
Philipp Heckel
adea3c38be Remove backslash from quoted strings 2022-04-27 13:56:21 -04:00
Philipp C. Heckel
fb56ab9a06 Merge pull request #225 from binwiederhier/actions-parsing
WIP: More advanced action parsing
2022-04-27 11:24:40 -04:00
Philipp Heckel
72aea2613a Remove superflous if statement 2022-04-27 11:23:44 -04:00
Philipp Heckel
6bd4e4bd7c User actions docs, tests and release notes 2022-04-27 10:25:01 -04:00
Philipp Heckel
1f6118f068 Finish up better parsing 2022-04-27 09:51:23 -04:00
Philipp Heckel
574e72a974 WIP: More advanced action parsing 2022-04-26 23:07:31 -04:00
Philipp Heckel
53646737e8 Changelog 2022-04-25 10:22:23 -04:00
waclaw66
26b9cc75ca Added translation using Weblate (Czech) 2022-04-25 15:07:10 +02:00
41 changed files with 6367 additions and 358 deletions

View File

@@ -520,23 +520,26 @@ or the root domain:
<VirtualHost *:80> <VirtualHost *:80>
ServerName ntfy.sh ServerName ntfy.sh
SetEnv proxy-nokeepalive 1 # Proxy connections to ntfy (requires "a2enmod proxy")
SetEnv proxy-sendchunked 1
ProxyPass / http://127.0.0.1:2586/ ProxyPass / http://127.0.0.1:2586/
ProxyPassReverse / 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 # Higher than the max message size of 4096 bytes
LimitRequestBody 102400 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:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC] RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L] RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want # Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
# it to work with curl without the annoying https:// prefix # it to work with curl without the annoying https:// prefix
RewriteEngine on
RewriteCond %{REQUEST_METHOD} GET RewriteCond %{REQUEST_METHOD} GET
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L] RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L]
</VirtualHost> </VirtualHost>
@@ -549,25 +552,23 @@ or the root domain:
SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf Include /etc/letsencrypt/options-ssl-apache.conf
SetEnv proxy-nokeepalive 1 # Proxy connections to ntfy (requires "a2enmod proxy")
SetEnv proxy-sendchunked 1
ProxyPass / http://127.0.0.1:2586/ ProxyPass / http://127.0.0.1:2586/
ProxyPassReverse / 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 # Higher than the max message size of 4096 bytes
LimitRequestBody 102400 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:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC] RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L] 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> </VirtualHost>
``` ```
@@ -578,6 +579,15 @@ or the root domain:
ntfy.sh, http://nfty.sh { ntfy.sh, http://nfty.sh {
reverse_proxy 127.0.0.1:2586 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}
} }
``` ```

View File

@@ -163,6 +163,138 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an
<figcaption>Urgent notification with tags and title</figcaption> <figcaption>Urgent notification with tags and title</figcaption>
</figure> </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>
![priority notification](static/img/android-screenshot-notification-multiline.jpg){ width=500 }
<figcaption>Notification using a click action, a user action, with an external image attachment and forwarded via email</figcaption>
</figure>
## Message title ## Message title
The notification title is typically set to the topic short URL (e.g. `ntfy.sh/mytopic`). To override the 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`). 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>, ...] <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. Multiple actions are separated by a semicolon (`;`), and key/value pairs are separated by commas (`,`). Values may be
The format has **some limitations**: You cannot use `,` or `;` in any of the values, and depending on your language/library, UTF-8 quoted with double quotes (`"`) or single quotes (`'`) if the value itself contains commas or semicolons.
characters may not work. Use the [JSON array format](#using-a-json-array) instead to overcome these limitations.
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 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: [`http` action](#send-http-request) section for details on the specific actions:
=== "Command line (curl)" === "Command line (curl)"
``` ```
body='{"temperature": 65}'
curl \ curl \
-d "You left the house. Turn down the A/C?" \ -d "You left the house. Turn down the A/C?" \
-H "Actions: view, Open portal, https://home.nest.com/, clear=true; \ -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" \ http, Turn down, https://api.nest.com/, body='$body'" \
ntfy.sh/myhome ntfy.sh/myhome
``` ```
=== "ntfy CLI" === "ntfy CLI"
``` ```
body='{"temperature": 65}'
ntfy publish \ ntfy publish \
--actions="view, Open portal, https://home.nest.com/, clear=true; \ --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 \ myhome \
"You left the house. Turn down the A/C?" "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 ``` http
POST /myhome HTTP/1.1 POST /myhome HTTP/1.1
Host: ntfy.sh 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? 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', method: 'POST',
body: 'You left the house. Turn down the A/C?', body: 'You left the house. Turn down the A/C?',
headers: { 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"
``` go ``` go
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("You left the house. Turn down the A/C?")) 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) http.DefaultClient.Do(req)
``` ```
=== "PowerShell" === "PowerShell"
``` powershell ``` powershell
$uri = "https://ntfy.sh/myhome" $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?" $body = "You left the house. Turn down the A/C?"
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing 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 ``` python
requests.post("https://ntfy.sh/myhome", requests.post("https://ntfy.sh/myhome",
data="You left the house. Turn down the A/C?", 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" === "PHP"
@@ -924,7 +1061,7 @@ As an example, here's how you can create the above notification using this forma
'method' => 'POST', 'method' => 'POST',
'header' => 'header' =>
"Content-Type: text/plain\r\n" . "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?' '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", "action": "http",
"label": "Turn down", "label": "Turn down",
"url": "https://api.nest.com/device/XZ1D2", "url": "https://api.nest.com/",
"body": "target_temp_f=65" "body": "{\"temperature\": 65}"
} }
] ]
}' }'
@@ -970,8 +1107,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
{ {
"action": "http", "action": "http",
"label": "Turn down", "label": "Turn down",
"url": "https://api.nest.com/device/XZ1D2", "url": "https://api.nest.com/",
"body": "target_temp_f=65" "body": "{\"temperature\": 65}"
} }
]' \ ]' \
myhome \ myhome \
@@ -996,8 +1133,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
{ {
"action": "http", "action": "http",
"label": "Turn down", "label": "Turn down",
"url": "https://api.nest.com/device/XZ1D2", "url": "https://api.nest.com/",
"body": "target_temp_f=65" "body": "{\"temperature\": 65}"
} }
] ]
} }
@@ -1020,8 +1157,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
{ {
action: "http", action: "http",
label: "Turn down", label: "Turn down",
url: "https://api.nest.com/device/XZ1D2", url: "https://api.nest.com/",
body: "target_temp_f=65" body: "{\"temperature\": 65}"
} }
] ]
}) })
@@ -1046,8 +1183,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
{ {
"action": "http", "action": "http",
"label": "Turn down", "label": "Turn down",
"url": "https://api.nest.com/device/XZ1D2", "url": "https://api.nest.com/",
"body": "target_temp_f=65" "body": "{\"temperature\": 65}"
} }
] ]
}` }`
@@ -1071,8 +1208,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
@{ @{
"action"="http", "action"="http",
"label"="Turn down" "label"="Turn down"
"url"="https://api.nest.com/device/XZ1D2" "url"="https://api.nest.com/"
"body"="target_temp_f=65" "body"="{\"temperature\": 65}"
} }
) )
} | ConvertTo-Json } | ConvertTo-Json
@@ -1095,8 +1232,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
{ {
"action": "http", "action": "http",
"label": "Turn down", "label": "Turn down",
"url": "https://api.nest.com/device/XZ1D2", "url": "https://api.nest.com/",
"body": "target_temp_f=65" "body": "{\"temperature\": 65}"
} }
] ]
}) })
@@ -1122,11 +1259,11 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
[ [
"action": "http", "action": "http",
"label": "Turn down", "label": "Turn down",
"url": "https://api.nest.com/device/XZ1D2", "url": "https://api.nest.com/",
"headers": [ "headers": [
"Authorization": "Bearer ..." "Authorization": "Bearer ..."
], ],
"body": "target_temp_f=65" "body": "{\"temperature\": 65}"
] ]
] ]
]) ])

View File

@@ -4,7 +4,64 @@ 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:**
* 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:** **Features:**
@@ -32,6 +89,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
**Additional translations:** **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/)) * 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/)) * 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), 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. @poblabs, and everyone I forgot for testing.
-->
## ntfy server v1.21.2 ## ntfy server v1.21.2
Released Apr 24, 2022 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:** **Features:**
* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134), * Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134),

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

307
server/actions.go Normal file
View 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
View 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)")
}

View File

@@ -539,7 +539,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
if actionsStr != "" { if actionsStr != "" {
m.Actions, err = parseActions(actionsStr) m.Actions, err = parseActions(actionsStr)
if err != nil { 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! 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 { 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 return errHTTPBadRequestWebSocketsUpgradeHeaderMissing
} }
if err := v.SubscriptionAllowed(); err != nil { if err := v.SubscriptionAllowed(); err != nil {

View File

@@ -56,6 +56,13 @@ type action struct {
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action 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 // publishMessage is used as input when publishing as JSON
type publishMessage struct { type publishMessage struct {
Topic string `json:"topic"` Topic string `json:"topic"`

View File

@@ -1,17 +1,10 @@
package server package server
import ( import (
"encoding/json"
"heckel.io/ntfy/util"
"net/http" "net/http"
"strings" "strings"
) )
const (
actionIDLength = 10
actionsMax = 3
)
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool { func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
value := strings.ToLower(readParam(r, names...)) value := strings.ToLower(readParam(r, names...))
if value == "" { if value == "" {
@@ -47,103 +40,3 @@ func readQueryParam(r *http.Request, names ...string) string {
} }
return "" 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
}

View File

@@ -27,56 +27,3 @@ func TestReadBoolParam(t *testing.T) {
require.Equal(t, false, up) require.Equal(t, false, up)
require.Equal(t, true, firebase) 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,35 @@
{ {
"name": "ntfy", "name": "ntfy",
"version": "1.0.0", "version": "1.0.0",
"url": "https://github.com/binwiederhier/ntfy",
"author": "Philipp C. Heckel <philipp.heckel@gmail.com>",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
"start-electron": "concurrently \"BROWSER=none npm start\" \"wait-on http://localhost:3000 && electron .\"",
"build": "react-scripts build", "build": "react-scripts build",
"build-electron": "react-scripts build --em.main=build/electron.js && electron-builder",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "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": { "dependencies": {
"@emotion/react": "^11.8.2", "@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1", "@emotion/styled": "^11.8.1",
@@ -15,6 +37,7 @@
"@mui/material": "latest", "@mui/material": "latest",
"dexie": "^3.2.1", "dexie": "^3.2.1",
"dexie-react-hooks": "^1.1.1", "dexie-react-hooks": "^1.1.1",
"electron-is-dev": "^2.0.0",
"i18next": "^21.6.14", "i18next": "^21.6.14",
"i18next-browser-languagedetector": "^6.1.4", "i18next-browser-languagedetector": "^6.1.4",
"i18next-http-backend": "^1.4.0", "i18next-http-backend": "^1.4.0",
@@ -28,6 +51,12 @@
"stacktrace-gps": "^3.0.4", "stacktrace-gps": "^3.0.4",
"stacktrace-js": "^2.0.2" "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": { "browserslist": {
"production": [ "production": [
">0.2%", ">0.2%",

42
web/public/electron.js Normal file
View 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();
}
});

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>ntfy web</title> <title>ntfy web</title>
<base href="/">
<!-- Mobile view --> <!-- Mobile view -->
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">

View File

@@ -150,5 +150,7 @@
"prefs_notifications_min_priority_description_max": "Показват се известията с приоритет 5 (най-висок)", "prefs_notifications_min_priority_description_max": "Показват се известията с приоритет 5 (най-висок)",
"prefs_notifications_delete_after_one_month_description": "Известията се премахват автоматично след един месец", "prefs_notifications_delete_after_one_month_description": "Известията се премахват автоматично след един месец",
"prefs_notifications_min_priority_description_any": "Показват се всички известия, независимо от приоритета им", "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": "Действието не се поддържа от приложението за уеб"
} }

View 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"
}

View File

@@ -150,5 +150,7 @@
"prefs_notifications_delete_after_three_hours_description": "Benachrichtigungen werden nach drei Stunden automatisch gelöscht", "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_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", "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}}"
} }

View File

@@ -1,29 +1,48 @@
{ {
"action_bar_show_menu": "Show menu",
"action_bar_logo_alt": "ntfy logo",
"action_bar_settings": "Settings", "action_bar_settings": "Settings",
"action_bar_send_test_notification": "Send test notification", "action_bar_send_test_notification": "Send test notification",
"action_bar_clear_notifications": "Clear all notifications", "action_bar_clear_notifications": "Clear all notifications",
"action_bar_unsubscribe": "Unsubscribe", "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_type_message": "Type a message here",
"message_bar_error_publishing": "Error publishing notification", "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_topics_title": "Subscribed topics",
"nav_button_all_notifications": "All notifications", "nav_button_all_notifications": "All notifications",
"nav_button_settings": "Settings", "nav_button_settings": "Settings",
"nav_button_documentation": "Documentation", "nav_button_documentation": "Documentation",
"nav_button_publish_message": "Publish notification", "nav_button_publish_message": "Publish notification",
"nav_button_subscribe": "Subscribe to topic", "nav_button_subscribe": "Subscribe to topic",
"nav_button_muted": "Notifications muted",
"nav_button_connecting": "connecting",
"alert_grant_title": "Notifications are disabled", "alert_grant_title": "Notifications are disabled",
"alert_grant_description": "Grant your browser permission to display desktop notifications.", "alert_grant_description": "Grant your browser permission to display desktop notifications.",
"alert_grant_button": "Grant now", "alert_grant_button": "Grant now",
"alert_not_supported_title": "Notifications not supported", "alert_not_supported_title": "Notifications not supported",
"alert_not_supported_description": "Notifications are not supported in your browser.", "alert_not_supported_description": "Notifications are not supported in your browser.",
"notifications_list": "Notifications list",
"notifications_list_item": "Notification",
"notifications_delete": "Delete notification",
"notifications_copied_to_clipboard": "Copied to clipboard", "notifications_copied_to_clipboard": "Copied to clipboard",
"notifications_tags": "Tags", "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_title": "Copy attachment URL to clipboard",
"notifications_attachment_copy_url_button": "Copy URL", "notifications_attachment_copy_url_button": "Copy URL",
"notifications_attachment_open_title": "Go to {{url}}", "notifications_attachment_open_title": "Go to {{url}}",
"notifications_attachment_open_button": "Open attachment", "notifications_attachment_open_button": "Open attachment",
"notifications_attachment_link_expires": "link expires {{date}}", "notifications_attachment_link_expires": "link expires {{date}}",
"notifications_attachment_link_expired": "download link expired", "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_title": "Copy link URL to clipboard",
"notifications_click_copy_url_button": "Copy link", "notifications_click_copy_url_button": "Copy link",
"notifications_click_open_button": "Open 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_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_file_reached": "exceeds {{fileSizeLimit}} file limit",
"publish_dialog_attachment_limits_quota_reached": "exceeds quota, {{remainingBytes}} remaining", "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_min": "Min. priority",
"publish_dialog_priority_low": "Low priority", "publish_dialog_priority_low": "Low priority",
"publish_dialog_priority_default": "Default 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_base_url_placeholder": "Service URL, e.g. https://example.com",
"publish_dialog_topic_label": "Topic name", "publish_dialog_topic_label": "Topic name",
"publish_dialog_topic_placeholder": "Topic name, e.g. phil_alerts", "publish_dialog_topic_placeholder": "Topic name, e.g. phil_alerts",
"publish_dialog_topic_reset": "Reset topic",
"publish_dialog_title_label": "Title", "publish_dialog_title_label": "Title",
"publish_dialog_title_placeholder": "Notification title, e.g. Disk space alert", "publish_dialog_title_placeholder": "Notification title, e.g. Disk space alert",
"publish_dialog_message_label": "Message", "publish_dialog_message_label": "Message",
@@ -65,14 +86,18 @@
"publish_dialog_priority_label": "Priority", "publish_dialog_priority_label": "Priority",
"publish_dialog_click_label": "Click URL", "publish_dialog_click_label": "Click URL",
"publish_dialog_click_placeholder": "URL that is opened when notification is clicked", "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_label": "Email",
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "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_label": "Attachment URL",
"publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", "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_label": "Filename",
"publish_dialog_filename_placeholder": "Attachment filename", "publish_dialog_filename_placeholder": "Attachment filename",
"publish_dialog_delay_label": "Delay", "publish_dialog_delay_label": "Delay",
"publish_dialog_delay_placeholder": "Delay delivery, e.g. {{unixTimestamp}}, {{relativeTime}}, or \"{{naturalLanguage}}\" (English only)", "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_other_features": "Other features:",
"publish_dialog_chip_click_label": "Click URL", "publish_dialog_chip_click_label": "Click URL",
"publish_dialog_chip_email_label": "Forward to email", "publish_dialog_chip_email_label": "Forward to email",
@@ -87,12 +112,15 @@
"publish_dialog_checkbox_publish_another": "Publish another", "publish_dialog_checkbox_publish_another": "Publish another",
"publish_dialog_attached_file_title": "Attached file:", "publish_dialog_attached_file_title": "Attached file:",
"publish_dialog_attached_file_filename_placeholder": "Attachment filename", "publish_dialog_attached_file_filename_placeholder": "Attachment filename",
"publish_dialog_attached_file_remove": "Remove attached file",
"publish_dialog_drop_file_here": "Drop file here", "publish_dialog_drop_file_here": "Drop file here",
"emoji_picker_search_placeholder": "Search emoji", "emoji_picker_search_placeholder": "Search emoji",
"emoji_picker_search_clear": "Clear search",
"subscribe_dialog_subscribe_title": "Subscribe to topic", "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_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_topic_placeholder": "Topic name, e.g. phil_alerts",
"subscribe_dialog_subscribe_use_another_label": "Use another server", "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_cancel": "Cancel",
"subscribe_dialog_subscribe_button_subscribe": "Subscribe", "subscribe_dialog_subscribe_button_subscribe": "Subscribe",
"subscribe_dialog_login_title": "Login required", "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_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_description_some": "Notifications play the {{sound}} sound when they arrive",
"prefs_notifications_sound_no_sound": "No sound", "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_title": "Minimum priority",
"prefs_notifications_min_priority_description_any": "Showing all notifications, regardless of 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", "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_notifications_delete_after_one_month_description": "Notifications are auto-deleted after one month",
"prefs_users_title": "Manage users", "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_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_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_user_header": "User",
"prefs_users_table_base_url_header": "Service URL", "prefs_users_table_base_url_header": "Service URL",
"prefs_users_dialog_title_add": "Add user", "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_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_button_copy_stack_trace": "Copy stack trace",
"error_boundary_stack_trace": "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>."
} }

View File

@@ -150,5 +150,7 @@
"priority_default": "predeterminada", "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_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", "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}}"
} }

View File

@@ -28,11 +28,11 @@
"notifications_example": "Exemple", "notifications_example": "Exemple",
"notifications_loading": "Chargement des notifications…", "notifications_loading": "Chargement des notifications…",
"publish_dialog_progress_uploading": "Téléversement…", "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_low": "Basse priorité",
"publish_dialog_priority_default": "Priorité par défaut", "publish_dialog_priority_default": "Priorité par défaut",
"publish_dialog_base_url_label": "URL du serveur", "publish_dialog_base_url_label": "URL du service",
"publish_dialog_base_url_placeholder": "URL du serveur, par ex. https://exemple.com", "publish_dialog_base_url_placeholder": "URL du service, par ex. https://exemple.com",
"publish_dialog_title_label": "Titre", "publish_dialog_title_label": "Titre",
"publish_dialog_message_label": "Message", "publish_dialog_message_label": "Message",
"publish_dialog_topic_label": "Nom du sujet", "publish_dialog_topic_label": "Nom du sujet",
@@ -46,11 +46,111 @@
"publish_dialog_message_published": "Notification publiée", "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_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_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}}", "publish_dialog_attachment_limits_file_reached": "Dépasse la limite du fichier {{fileSizeLimit}}",
"nav_button_subscribe": "S'abonner au sujet", "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_title": "Les notifications sont désactivées",
"alert_grant_description": "Autorisez votre navigateur à afficher les notifications du bureau.", "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"
} }

View File

@@ -150,5 +150,7 @@
"prefs_notifications_delete_after_never_description": "Notifikasi tidak pernah dihapus secara otomatis", "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", "prefs_notifications_delete_after_one_day_description": "Notifikasi dihapus secara otomatis setelah satu hari",
"priority_default": "bawaan", "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}}"
} }

View File

@@ -0,0 +1 @@
{}

View File

@@ -150,5 +150,7 @@
"priority_default": "öntanımlı", "priority_default": "öntanımlı",
"prefs_notifications_min_priority_description_max": "Öncelik 5 (en fazla) ise bildirimleri göster", "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", "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"
} }

View File

@@ -44,16 +44,22 @@ const ActionBar = (props) => {
<IconButton <IconButton
color="inherit" color="inherit"
edge="start" edge="start"
aria-label={t("action_bar_show_menu")}
onClick={props.onMobileDrawerToggle} onClick={props.onMobileDrawerToggle}
sx={{ mr: 2, display: { sm: 'none' } }} sx={{ mr: 2, display: { sm: 'none' } }}
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
<Box component="img" src={logo} sx={{ <Box
display: { xs: 'none', sm: 'block' }, component="img"
marginRight: '10px', src={logo}
height: '28px' 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 }}> <Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{title} {title}
</Typography> </Typography>
@@ -173,10 +179,10 @@ const SettingsIcons = (props) => {
return ( 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/>} {subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
</IconButton> </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/> <MoreVertIcon/>
</IconButton> </IconButton>
<Popper <Popper

View File

@@ -5,27 +5,36 @@ import fileImage from "../img/file-image.svg";
import fileVideo from "../img/file-video.svg"; import fileVideo from "../img/file-video.svg";
import fileAudio from "../img/file-audio.svg"; import fileAudio from "../img/file-audio.svg";
import fileApp from "../img/file-app.svg"; import fileApp from "../img/file-app.svg";
import {useTranslation} from "react-i18next";
const AttachmentIcon = (props) => { const AttachmentIcon = (props) => {
const { t } = useTranslation();
const type = props.type; const type = props.type;
let imageFile; let imageFile, imageLabel;
if (!type) { if (!type) {
imageFile = fileDocument; imageFile = fileDocument;
imageLabel = t("notifications_attachment_file_image");
} else if (type.startsWith('image/')) { } else if (type.startsWith('image/')) {
imageFile = fileImage; imageFile = fileImage;
imageLabel = t("notifications_attachment_file_video");
} else if (type.startsWith('video/')) { } else if (type.startsWith('video/')) {
imageFile = fileVideo; imageFile = fileVideo;
imageLabel = t("notifications_attachment_file_video");
} else if (type.startsWith('audio/')) { } else if (type.startsWith('audio/')) {
imageFile = fileAudio; imageFile = fileAudio;
imageLabel = t("notifications_attachment_file_audio");
} else if (type === "application/vnd.android.package-archive") { } else if (type === "application/vnd.android.package-archive") {
imageFile = fileApp; imageFile = fileApp;
imageLabel = t("notifications_attachment_file_app");
} else { } else {
imageFile = fileDocument; imageFile = fileDocument;
imageLabel = t("notifications_attachment_file_document");
} }
return ( return (
<Box <Box
component="img" component="img"
src={imageFile} src={imageFile}
alt={imageLabel}
loading="lazy" loading="lazy"
sx={{ sx={{
width: '28px', width: '28px',

View File

@@ -12,11 +12,15 @@ const DialogFooter = (props) => {
paddingLeft: '24px', paddingLeft: '24px',
paddingBottom: '8px', paddingBottom: '8px',
}}> }}>
<DialogContentText component="div" sx={{ <DialogContentText
margin: '0px', component="div"
paddingTop: '12px', aria-live="polite"
paddingBottom: '4px' sx={{
}}> margin: '0px',
paddingTop: '12px',
paddingBottom: '4px'
}}
>
{props.status} {props.status}
</DialogContentText> </DialogContentText>
<DialogActions sx={{paddingRight: 2}}> <DialogActions sx={{paddingRight: 2}}>

View File

@@ -80,10 +80,16 @@ const EmojiPicker = (props) => {
variant="standard" variant="standard"
fullWidth fullWidth
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }} sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
inputProps={{
role: "searchbox",
"aria-label": t("emoji_picker_search_placeholder")
}}
InputProps={{ InputProps={{
endAdornment: endAdornment:
<InputAdornment position="end" sx={{ display: (search) ? '' : 'none' }}> <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> </InputAdornment>
}} }}
/> />
@@ -130,10 +136,12 @@ const Category = (props) => {
const Emoji = (props) => { const Emoji = (props) => {
const emoji = props.emoji; const emoji = props.emoji;
const matches = emojiMatches(emoji, props.search); const matches = emojiMatches(emoji, props.search);
const title = `${emoji.description} (${emoji.aliases[0]})`;
return ( return (
<EmojiDiv <EmojiDiv
onClick={props.onClick} onClick={props.onClick}
title={`${emoji.description} (${emoji.aliases[0]})`} title={title}
aria-label={title}
style={{ display: (matches) ? '' : 'none' }} style={{ display: (matches) ? '' : 'none' }}
> >
{props.emoji.emoji} {props.emoji.emoji}

View File

@@ -10,13 +10,28 @@ class ErrorBoundaryImpl extends React.Component {
this.state = { this.state = {
error: false, error: false,
originalStack: null, originalStack: null,
niceStack: null niceStack: null,
unsupportedIndexedDB: false
}; };
} }
componentDidCatch(error, info) { componentDidCatch(error, info) {
console.error("[ErrorBoundary] Error caught", 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 // Immediately render original stack trace
const prettierOriginalStack = info.componentStack const prettierOriginalStack = info.componentStack
.trim() .trim()
@@ -36,6 +51,13 @@ class ErrorBoundaryImpl extends React.Component {
}); });
} }
handleUnsupportedIndexedDB() {
this.setState({
error: true,
unsupportedIndexedDB: true
});
}
copyStack() { copyStack() {
let stack = ""; let stack = "";
if (this.state.niceStack) { if (this.state.niceStack) {
@@ -46,34 +68,61 @@ class ErrorBoundaryImpl extends React.Component {
} }
render() { render() {
const { t } = this.props;
if (this.state.error) { if (this.state.error) {
return ( if (this.state.unsupportedIndexedDB) {
<div style={{margin: '20px'}}> return this.renderUnsupportedIndexedDB();
<h2>{t("error_boundary_title")} 😮</h2> } else {
<p> return this.renderError();
<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>
);
} }
return this.props.children; 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 const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t

View File

@@ -75,13 +75,15 @@ const MessageBar = (props) => {
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] 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/> <KeyboardArrowUpIcon/>
</IconButton> </IconButton>
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"
placeholder={t("message_bar_type_message")} placeholder={t("message_bar_type_message")}
aria-label={t("message_bar_type_message")}
role="textbox"
type="text" type="text"
fullWidth fullWidth
variant="standard" 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/> <SendIcon/>
</IconButton> </IconButton>
<Portal> <Portal>

View File

@@ -31,10 +31,15 @@ const navWidth = 280;
const Navigation = (props) => { const Navigation = (props) => {
const navigationList = <NavList {...props}/>; const navigationList = <NavList {...props}/>;
return ( 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 */} {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
<Drawer <Drawer
variant="temporary" variant="temporary"
role="menubar"
open={props.mobileDrawerOpen} open={props.mobileDrawerOpen}
onClose={props.onMobileDrawerToggle} onClose={props.onMobileDrawerToggle}
ModalProps={{ keepMounted: true }} // Better open performance on mobile. ModalProps={{ keepMounted: true }} // Better open performance on mobile.
@@ -49,6 +54,7 @@ const Navigation = (props) => {
<Drawer <Drawer
open open
variant="permanent" variant="permanent"
role="menubar"
sx={{ sx={{
display: { xs: 'none', sm: 'block' }, display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth }, '& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
@@ -157,6 +163,7 @@ const SubscriptionList = (props) => {
} }
const SubscriptionItem = (props) => { const SubscriptionItem = (props) => {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const subscription = props.subscription; const subscription = props.subscription;
const iconBadge = (subscription.new <= 99) ? subscription.new : "99+"; const iconBadge = (subscription.new <= 99) ? subscription.new : "99+";
@@ -166,16 +173,19 @@ const SubscriptionItem = (props) => {
const label = (subscription.baseUrl === window.location.origin) const label = (subscription.baseUrl === window.location.origin)
? subscription.topic ? subscription.topic
: topicShortUrl(subscription.baseUrl, subscription.topic); : topicShortUrl(subscription.baseUrl, subscription.topic);
const ariaLabel = (subscription.state === ConnectionState.Connecting)
? `${label} (${t("nav_button_connecting")})`
: label;
const handleClick = async () => { const handleClick = async () => {
navigate(routes.forSubscription(subscription)); navigate(routes.forSubscription(subscription));
await subscriptionManager.markNotificationsRead(subscription.id); await subscriptionManager.markNotificationsRead(subscription.id);
}; };
return ( return (
<ListItemButton onClick={handleClick} selected={props.selected}> <ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
<ListItemIcon>{icon}</ListItemIcon> <ListItemIcon>{icon}</ListItemIcon>
<ListItemText primary={label}/> <ListItemText primary={label}/>
{subscription.mutedUntil > 0 && {subscription.mutedUntil > 0 &&
<ListItemIcon edge="end"><NotificationsOffOutlined /></ListItemIcon>} <ListItemIcon edge="end" aria-label={t("nav_button_muted")}><NotificationsOffOutlined /></ListItemIcon>}
</ListItemButton> </ListItemButton>
); );
}; };

View File

@@ -98,6 +98,8 @@ const NotificationList = (props) => {
> >
<Container <Container
maxWidth="md" maxWidth="md"
role="list"
aria-label={t("notifications_list")}
sx={{ sx={{
marginTop: 3, marginTop: 3,
marginBottom: (props.messageBar) ? "100px" : 3 // Hack to avoid hiding notifications behind the message bar 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 hasUserActions = notification.actions && notification.actions.length > 0;
const showActions = hasAttachmentActions || hasClickAction || hasUserActions; const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
return ( return (
<Card sx={{ minWidth: 275, padding: 1 }}> <Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
<CardContent> <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 /> <CloseIcon />
</IconButton> </IconButton>
<Typography sx={{ fontSize: 14 }} color="text.secondary"> <Typography sx={{ fontSize: 14 }} color="text.secondary">
@@ -153,15 +155,15 @@ const NotificationItem = (props) => {
{[1,2,4,5].includes(notification.priority) && {[1,2,4,5].includes(notification.priority) &&
<img <img
src={priorityFiles[notification.priority]} src={priorityFiles[notification.priority]}
alt={`Priority ${notification.priority}`} alt={t("notifications_priority_x", { priority: notification.priority})}
style={{ verticalAlign: 'bottom' }} style={{ verticalAlign: 'bottom' }}
/>} />}
{notification.new === 1 && {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"/> <circle cx="50" cy="50" r="50" fill="#338574"/>
</svg>} </svg>}
</Typography> </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' }}> <Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>
{autolink(maybeAppendActionErrors(formatMessage(notification), notification))} {autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
</Typography> </Typography>
@@ -289,6 +291,7 @@ const Attachment = (props) => {
}; };
const Image = (props) => { const Image = (props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<> <>
@@ -296,6 +299,7 @@ const Image = (props) => {
component="img" component="img"
src={props.attachment.url} src={props.attachment.url}
loading="lazy" loading="lazy"
alt={t("notifications_attachment_image")}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
sx={{ sx={{
marginTop: 2, marginTop: 2,
@@ -316,6 +320,7 @@ const Image = (props) => {
<Box <Box
component="img" component="img"
src={props.attachment.url} src={props.attachment.url}
alt={t("notifications_attachment_image")}
loading="lazy" loading="lazy"
sx={{ sx={{
maxWidth: 1, maxWidth: 1,
@@ -347,13 +352,16 @@ const UserAction = (props) => {
if (action.action === "broadcast") { if (action.action === "broadcast") {
return ( return (
<Tooltip title={t("notifications_actions_not_supported")}> <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> </Tooltip>
); );
} else if (action.action === "view") { } else if (action.action === "view") {
return ( return (
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}> <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> </Tooltip>
); );
} else if (action.action === "http") { } else if (action.action === "http") {
@@ -361,7 +369,10 @@ const UserAction = (props) => {
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? ""); const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
return ( return (
<Tooltip title={t("notifications_actions_http_request_title", { method: method, url: action.url })}> <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> </Tooltip>
); );
} }
@@ -416,7 +427,7 @@ const NoNotifications = (props) => {
return ( return (
<VerticallyCenteredContainer maxWidth="xs"> <VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <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")} {t("notifications_none_for_topic_title")}
</Typography> </Typography>
<Paragraph> <Paragraph>
@@ -442,7 +453,7 @@ const NoNotificationsWithoutSubscription = (props) => {
return ( return (
<VerticallyCenteredContainer maxWidth="xs"> <VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <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")} {t("notifications_none_for_any_title")}
</Typography> </Typography>
<Paragraph> <Paragraph>
@@ -466,7 +477,7 @@ const NoSubscriptions = () => {
return ( return (
<VerticallyCenteredContainer maxWidth="xs"> <VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}> <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")} {t("notifications_no_subscriptions_title")}
</Typography> </Typography>
<Paragraph> <Paragraph>

View File

@@ -34,11 +34,6 @@ import DialogActions from "@mui/material/DialogActions";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import {playSound, shuffle, sounds, validUrl} from "../app/utils"; import {playSound, shuffle, sounds, validUrl} from "../app/utils";
import {useTranslation} from "react-i18next"; 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 = () => { const Preferences = () => {
return ( return (
@@ -55,7 +50,7 @@ const Preferences = () => {
const Notifications = () => { const Notifications = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Card sx={{p: 3}}> <Card sx={{p: 3}} aria-label={t("prefs_notifications_title")}>
<Typography variant="h5" sx={{marginBottom: 2}}> <Typography variant="h5" sx={{marginBottom: 2}}>
{t("prefs_notifications_title")} {t("prefs_notifications_title")}
</Typography> </Typography>
@@ -70,6 +65,7 @@ const Notifications = () => {
const Sound = () => { const Sound = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const labelId = "prefSound";
const sound = useLiveQuery(async () => prefs.sound()); const sound = useLiveQuery(async () => prefs.sound());
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setSound(ev.target.value); await prefs.setSound(ev.target.value);
@@ -84,15 +80,15 @@ const Sound = () => {
description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label }); description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label });
} }
return ( 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%' }}> <div style={{ display: 'flex', width: '100%' }}>
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}> <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> <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>)} {Object.entries(sounds).map(s => <MenuItem key={s[0]} value={s[0]}>{s[1].label}</MenuItem>)}
</Select> </Select>
</FormControl> </FormControl>
<IconButton onClick={() => playSound(sound)} disabled={sound === "none"}> <IconButton onClick={() => playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}>
<PlayArrowIcon /> <PlayArrowIcon />
</IconButton> </IconButton>
</div> </div>
@@ -102,6 +98,7 @@ const Sound = () => {
const MinPriority = () => { const MinPriority = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const labelId = "prefMinPriority";
const minPriority = useLiveQuery(async () => prefs.minPriority()); const minPriority = useLiveQuery(async () => prefs.minPriority());
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setMinPriority(ev.target.value); await prefs.setMinPriority(ev.target.value);
@@ -128,9 +125,9 @@ const MinPriority = () => {
}); });
} }
return ( 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 }}> <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={1}>{t("prefs_notifications_min_priority_any")}</MenuItem>
<MenuItem value={2}>{t("prefs_notifications_min_priority_low_and_higher")}</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> <MenuItem value={3}>{t("prefs_notifications_min_priority_default_and_higher")}</MenuItem>
@@ -144,6 +141,7 @@ const MinPriority = () => {
const DeleteAfter = () => { const DeleteAfter = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const labelId = "prefDeleteAfter";
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setDeleteAfter(ev.target.value); await prefs.setDeleteAfter(ev.target.value);
@@ -161,9 +159,9 @@ const DeleteAfter = () => {
} }
})(); })();
return ( 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 }}> <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={0}>{t("prefs_notifications_delete_after_never")}</MenuItem>
<MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem> <MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem>
<MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem> <MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem>
@@ -177,7 +175,7 @@ const DeleteAfter = () => {
const PrefGroup = (props) => { const PrefGroup = (props) => {
return ( return (
<div> <div role="table">
{props.children} {props.children}
</div> </div>
) )
@@ -185,28 +183,39 @@ const PrefGroup = (props) => {
const Pref = (props) => { const Pref = (props) => {
return ( return (
<div style={{ <div
display: "flex", role="row"
flexDirection: "row", style={{
marginTop: "10px", display: "flex",
marginBottom: "20px", flexDirection: "row",
}}> marginTop: "10px",
<div style={{ marginBottom: "20px",
flex: '1 0 40%', }}
display: 'flex', >
flexDirection: 'column', <div
justifyContent: 'center', role="cell"
paddingRight: '30px' 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> <div><b>{props.title}</b></div>
{props.description && <div><em>{props.description}</em></div>} {props.description && <div><em>{props.description}</em></div>}
</div> </div>
<div style={{ <div
flex: '1 0 calc(60% - 50px)', role="cell"
display: 'flex', style={{
flexDirection: 'column', flex: '1 0 calc(60% - 50px)',
justifyContent: 'center' display: 'flex',
}}> flexDirection: 'column',
justifyContent: 'center'
}}
>
{props.children} {props.children}
</div> </div>
</div> </div>
@@ -235,7 +244,7 @@ const Users = () => {
} }
}; };
return ( return (
<Card sx={{ padding: 1 }}> <Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
<CardContent sx={{ paddingBottom: 1 }}> <CardContent sx={{ paddingBottom: 1 }}>
<Typography variant="h5" sx={{marginBottom: 2}}> <Typography variant="h5" sx={{marginBottom: 2}}>
{t("prefs_users_title")} {t("prefs_users_title")}
@@ -291,7 +300,7 @@ const UserTable = (props) => {
} }
}; };
return ( return (
<Table size="small"> <Table size="small" aria-label={t("prefs_users_table")}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell> <TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell>
@@ -305,13 +314,13 @@ const UserTable = (props) => {
key={user.baseUrl} key={user.baseUrl}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
> >
<TableCell component="th" scope="row" sx={{paddingLeft: 0}}>{user.username}</TableCell> <TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
<TableCell>{user.baseUrl}</TableCell> <TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
<TableCell align="right"> <TableCell align="right">
<IconButton onClick={() => handleEditClick(user)}> <IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
<EditIcon/> <EditIcon/>
</IconButton> </IconButton>
<IconButton onClick={() => handleDeleteClick(user)}> <IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
</TableCell> </TableCell>
@@ -371,6 +380,7 @@ const UserDialog = (props) => {
margin="dense" margin="dense"
id="baseUrl" id="baseUrl"
label={t("prefs_users_dialog_base_url_label")} label={t("prefs_users_dialog_base_url_label")}
aria-label={t("prefs_users_dialog_base_url_label")}
value={baseUrl} value={baseUrl}
onChange={ev => setBaseUrl(ev.target.value)} onChange={ev => setBaseUrl(ev.target.value)}
type="url" type="url"
@@ -382,6 +392,7 @@ const UserDialog = (props) => {
margin="dense" margin="dense"
id="username" id="username"
label={t("prefs_users_dialog_username_label")} label={t("prefs_users_dialog_username_label")}
aria-label={t("prefs_users_dialog_username_label")}
value={username} value={username}
onChange={ev => setUsername(ev.target.value)} onChange={ev => setUsername(ev.target.value)}
type="text" type="text"
@@ -392,6 +403,7 @@ const UserDialog = (props) => {
margin="dense" margin="dense"
id="password" id="password"
label={t("prefs_users_dialog_password_label")} label={t("prefs_users_dialog_password_label")}
aria-label={t("prefs_users_dialog_password_label")}
type="password" type="password"
value={password} value={password}
onChange={ev => setPassword(ev.target.value)} onChange={ev => setPassword(ev.target.value)}
@@ -410,7 +422,7 @@ const UserDialog = (props) => {
const Appearance = () => { const Appearance = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Card sx={{p: 3}}> <Card sx={{p: 3}} aria-label={t("prefs_appearance_title")}>
<Typography variant="h5" sx={{marginBottom: 2}}> <Typography variant="h5" sx={{marginBottom: 2}}>
{t("prefs_appearance_title")} {t("prefs_appearance_title")}
</Typography> </Typography>
@@ -423,21 +435,25 @@ const Appearance = () => {
const Language = () => { const Language = () => {
const { t, i18n } = useTranslation(); 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 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. // 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 // Languages names from: https://www.omniglot.com/language/names.htm
// Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
return ( return (
<Pref title={title}> <Pref labelId={labelId} title={title}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}> <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="en">English</MenuItem>
<MenuItem value="es">Español</MenuItem>
<MenuItem value="bg">Български</MenuItem> <MenuItem value="bg">Български</MenuItem>
<MenuItem value="cs">Čeština</MenuItem>
<MenuItem value="de">Deutsch</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="id">Bahasa Indonesia</MenuItem>
<MenuItem value="ja">日本語</MenuItem> <MenuItem value="ja">日本語</MenuItem>
<MenuItem value="nb_NO">Norsk bokmål</MenuItem> <MenuItem value="nb_NO">Norsk bokmål</MenuItem>

View File

@@ -232,7 +232,7 @@ const PublishDialog = (props) => {
<DialogContent> <DialogContent>
{dropZone && <DropBox/>} {dropZone && <DropBox/>}
{showTopicUrl && {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); setBaseUrl(props.baseUrl);
setTopic(props.topic); setTopic(props.topic);
setShowTopicUrl(false); setShowTopicUrl(false);
@@ -247,6 +247,9 @@ const PublishDialog = (props) => {
type="url" type="url"
variant="standard" variant="standard"
sx={{flexGrow: 1, marginRight: 1}} sx={{flexGrow: 1, marginRight: 1}}
inputProps={{
"aria-label": t("publish_dialog_base_url_label")
}}
/> />
<TextField <TextField
margin="dense" margin="dense"
@@ -259,6 +262,9 @@ const PublishDialog = (props) => {
variant="standard" variant="standard"
autoFocus={!messageFocused} autoFocus={!messageFocused}
sx={{flexGrow: 1}} sx={{flexGrow: 1}}
inputProps={{
"aria-label": t("publish_dialog_topic_label")
}}
/> />
</ClosableRow> </ClosableRow>
} }
@@ -272,6 +278,9 @@ const PublishDialog = (props) => {
type="text" type="text"
fullWidth fullWidth
variant="standard" variant="standard"
inputProps={{
"aria-label": t("publish_dialog_title_label")
}}
/> />
<TextField <TextField
margin="dense" margin="dense"
@@ -286,6 +295,9 @@ const PublishDialog = (props) => {
autoFocus={messageFocused} autoFocus={messageFocused}
fullWidth fullWidth
multiline multiline
inputProps={{
"aria-label": t("publish_dialog_message_label")
}}
/> />
<div style={{display: 'flex'}}> <div style={{display: 'flex'}}>
<EmojiPicker <EmojiPicker
@@ -293,7 +305,7 @@ const PublishDialog = (props) => {
onEmojiPick={handleEmojiPick} onEmojiPick={handleEmojiPick}
onClose={handleEmojiClose} onClose={handleEmojiClose}
/> />
<DialogIconButton disabled={disabled} onClick={handleEmojiClick}> <DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
<InsertEmoticonIcon/> <InsertEmoticonIcon/>
</DialogIconButton> </DialogIconButton>
<TextField <TextField
@@ -306,6 +318,9 @@ const PublishDialog = (props) => {
type="text" type="text"
variant="standard" variant="standard"
sx={{flexGrow: 1, marginRight: 1}} sx={{flexGrow: 1, marginRight: 1}}
inputProps={{
"aria-label": t("publish_dialog_tags_label")
}}
/> />
<FormControl <FormControl
variant="standard" variant="standard"
@@ -319,11 +334,14 @@ const PublishDialog = (props) => {
value={priority} value={priority}
onChange={(ev) => setPriority(ev.target.value)} onChange={(ev) => setPriority(ev.target.value)}
disabled={disabled} disabled={disabled}
inputProps={{
"aria-label": t("publish_dialog_priority_label")
}}
> >
{[5,4,3,2,1].map(priority => {[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' }}> <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>{priorities[priority].label}</div>
</div> </div>
</MenuItem> </MenuItem>
@@ -332,7 +350,7 @@ const PublishDialog = (props) => {
</FormControl> </FormControl>
</div> </div>
{showClickUrl && {showClickUrl &&
<ClosableRow disabled={disabled} onClose={() => { <ClosableRow disabled={disabled} closeLabel={t("publish_dialog_click_reset")} onClose={() => {
setClickUrl(""); setClickUrl("");
setShowClickUrl(false); setShowClickUrl(false);
}}> }}>
@@ -346,11 +364,14 @@ const PublishDialog = (props) => {
type="url" type="url"
fullWidth fullWidth
variant="standard" variant="standard"
inputProps={{
"aria-label": t("publish_dialog_click_label")
}}
/> />
</ClosableRow> </ClosableRow>
} }
{showEmail && {showEmail &&
<ClosableRow disabled={disabled} onClose={() => { <ClosableRow disabled={disabled} closeLabel={t("publish_dialog_email_reset")} onClose={() => {
setEmail(""); setEmail("");
setShowEmail(false); setShowEmail(false);
}}> }}>
@@ -364,11 +385,14 @@ const PublishDialog = (props) => {
type="email" type="email"
variant="standard" variant="standard"
fullWidth fullWidth
inputProps={{
"aria-label": t("publish_dialog_email_label")
}}
/> />
</ClosableRow> </ClosableRow>
} }
{showAttachUrl && {showAttachUrl &&
<ClosableRow disabled={disabled} onClose={() => { <ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
setAttachUrl(""); setAttachUrl("");
setFilename(""); setFilename("");
setFilenameEdited(false); setFilenameEdited(false);
@@ -398,6 +422,9 @@ const PublishDialog = (props) => {
type="url" type="url"
variant="standard" variant="standard"
sx={{flexGrow: 5, marginRight: 1}} sx={{flexGrow: 5, marginRight: 1}}
inputProps={{
"aria-label": t("publish_dialog_attach_label")
}}
/> />
<TextField <TextField
margin="dense" margin="dense"
@@ -412,6 +439,9 @@ const PublishDialog = (props) => {
type="text" type="text"
variant="standard" variant="standard"
sx={{flexGrow: 1}} sx={{flexGrow: 1}}
inputProps={{
"aria-label": t("publish_dialog_filename_label")
}}
/> />
</ClosableRow> </ClosableRow>
} }
@@ -420,6 +450,7 @@ const PublishDialog = (props) => {
ref={attachFileInput} ref={attachFileInput}
onChange={handleAttachFileChanged} onChange={handleAttachFileChanged}
style={{ display: 'none' }} style={{ display: 'none' }}
aria-hidden={true}
/> />
{showAttachFile && <AttachmentBox {showAttachFile && <AttachmentBox
file={attachFile} file={attachFile}
@@ -434,7 +465,7 @@ const PublishDialog = (props) => {
}} }}
/>} />}
{showDelay && {showDelay &&
<ClosableRow disabled={disabled} onClose={() => { <ClosableRow disabled={disabled} closeLabel={t("publish_dialog_delay_reset")} onClose={() => {
setDelay(""); setDelay("");
setShowDelay(false); setShowDelay(false);
}}> }}>
@@ -452,6 +483,9 @@ const PublishDialog = (props) => {
type="text" type="text"
variant="standard" variant="standard"
fullWidth fullWidth
inputProps={{
"aria-label": t("publish_dialog_delay_label")
}}
/> />
</ClosableRow> </ClosableRow>
} }
@@ -459,12 +493,12 @@ const PublishDialog = (props) => {
{t("publish_dialog_other_features")} {t("publish_dialog_other_features")}
</Typography> </Typography>
<div> <div>
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(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")} onClick={() => setShowEmail(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")} onClick={() => setShowAttachUrl(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")} onClick={() => handleAttachFileClick()} 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")} onClick={() => setShowDelay(true)} 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")} onClick={() => setShowTopicUrl(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> </div>
<Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}> <Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}>
<Trans <Trans
@@ -483,7 +517,13 @@ const PublishDialog = (props) => {
label={t("publish_dialog_checkbox_publish_another")} label={t("publish_dialog_checkbox_publish_another")}
sx={{marginRight: 2}} sx={{marginRight: 2}}
control={ 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={props.onClose}>{t("publish_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>{t("publish_dialog_button_send")}</Button> <Button onClick={handleSubmit} disabled={!sendButtonEnabled}>{t("publish_dialog_button_send")}</Button>
@@ -497,7 +537,7 @@ const PublishDialog = (props) => {
const Row = (props) => { const Row = (props) => {
return ( return (
<div style={{display: 'flex'}}> <div style={{display: 'flex'}} role="row">
{props.children} {props.children}
</div> </div>
); );
@@ -508,7 +548,11 @@ const ClosableRow = (props) => {
return ( return (
<Row> <Row>
{props.children} {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> </Row>
); );
}; };
@@ -523,6 +567,7 @@ const DialogIconButton = (props) => {
sx={{height: "45px", marginTop: "17px", ...sx}} sx={{height: "45px", marginTop: "17px", ...sx}}
onClick={props.onClick} onClick={props.onClick}
disabled={props.disabled} disabled={props.disabled}
aria-label={props["aria-label"]}
> >
{props.children} {props.children}
</IconButton> </IconButton>
@@ -557,13 +602,15 @@ const AttachmentBox = (props) => {
<Typography variant="body2" sx={{ color: 'text.primary' }}> <Typography variant="body2" sx={{ color: 'text.primary' }}>
{formatBytes(file.size)} {formatBytes(file.size)}
{props.error && {props.error &&
<Typography component="span" sx={{ color: 'error.main' }}> <Typography component="span" sx={{ color: 'error.main' }} aria-live="polite">
{" "}({props.error}) {" "}({props.error})
</Typography> </Typography>
} }
</Typography> </Typography>
</Box> </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> </Box>
</> </>
); );
@@ -588,6 +635,7 @@ const ExpandingTextField = (props) => {
ref={invisibleFieldRef} ref={invisibleFieldRef}
component="span" component="span"
variant={props.variant} variant={props.variant}
aria-hidden={true}
sx={{position: "absolute", left: "-200%"}} sx={{position: "absolute", left: "-200%"}}
> >
{props.value} {props.value}
@@ -601,7 +649,10 @@ const ExpandingTextField = (props) => {
variant="standard" variant="standard"
sx={{ width: `${textWidth}px`, borderBottom: "none" }} sx={{ width: `${textWidth}px`, borderBottom: "none" }}
InputProps={{ style: { fontSize: theme.typography[props.variant].fontSize } }} 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} disabled={props.disabled}
/> />
</> </>

View File

@@ -102,16 +102,26 @@ const SubscribePage = (props) => {
margin="dense" margin="dense"
id="topic" id="topic"
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")} placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
inputProps={{ maxLength: 64 }}
value={props.topic} value={props.topic}
onChange={ev => props.setTopic(ev.target.value)} onChange={ev => props.setTopic(ev.target.value)}
type="text" type="text"
fullWidth fullWidth
variant="standard" variant="standard"
inputProps={{
maxLength: 64,
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder")
}}
/> />
<FormControlLabel <FormControlLabel
sx={{pt: 1}} 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")} /> label={t("subscribe_dialog_subscribe_use_another_label")} />
{anotherServerVisible && <Autocomplete {anotherServerVisible && <Autocomplete
freeSolo freeSolo
@@ -120,7 +130,12 @@ const SubscribePage = (props) => {
inputValue={props.baseUrl} inputValue={props.baseUrl}
onInputChange={(ev, newVal) => props.setBaseUrl(newVal)} onInputChange={(ev, newVal) => props.setBaseUrl(newVal)}
renderInput={ (params) => 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> </DialogContent>
@@ -168,6 +183,9 @@ const LoginPage = (props) => {
type="text" type="text"
fullWidth fullWidth
variant="standard" variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_username_label")
}}
/> />
<TextField <TextField
margin="dense" margin="dense"
@@ -178,6 +196,9 @@ const LoginPage = (props) => {
onChange={ev => setPassword(ev.target.value)} onChange={ev => setPassword(ev.target.value)}
fullWidth fullWidth
variant="standard" variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_password_label")
}}
/> />
</DialogContent> </DialogContent>
<DialogFooter status={errorText}> <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.