Merge branch '1364-copy-action'

This commit is contained in:
binwiederhier
2026-02-08 20:46:54 -05:00
9 changed files with 377 additions and 22 deletions

View File

@@ -1134,6 +1134,7 @@ As of today, the following actions are supported:
* [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent * [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
when the action button is tapped (only supported on Android) when the action button is tapped (only supported on Android)
* [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped * [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped
* [`copy`](#copy-to-clipboard): Copies a given value to the clipboard when the action button is tapped
Here's an example of what a notification with actions can look like: Here's an example of what a notification with actions can look like:
@@ -1164,9 +1165,12 @@ To define actions using the `X-Actions` header (or any of its aliases: `Actions`
Multiple actions are separated by a semicolon (`;`), and key/value pairs are separated by commas (`,`). Values may be Multiple actions are separated by a semicolon (`;`), and key/value pairs are separated by commas (`,`). Values may be
quoted with double quotes (`"`) or single quotes (`'`) if the value itself contains commas or semicolons. quoted with double quotes (`"`) or single quotes (`'`) if the value itself contains commas or semicolons.
The `action=` and `label=` prefix are optional in all actions, and the `url=` prefix is optional in the `view` and Each action type has a short format where some key prefixes can be omitted:
`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. * [`view`](#open-websiteapp): `view, <label>, <url>[, clear=true]`
* [`broadcast`](#send-android-broadcast):`broadcast, <label>[, extras.<param>=<value>][, intent=<intent>][, clear=true]`
* [`http`](#send-http-request): `http, <label>, <url>[, method=<method>][, headers.<header>=<value>][, body=<body>][, clear=true]`
* [`copy`](#copy-to-clipboard): `copy, <label>, <value>[, clear=true]`
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:
@@ -1466,8 +1470,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
``` ```
The required/optional fields for each action depend on the type of the action itself. Please refer to The required/optional fields for each action depend on the type of the action itself. Please refer to
[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), and [`http` action](#send-http-request) [`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), [`http` action](#send-http-request),
for details. and [`copy` action](#copy-to-clipboard) for details.
### Open website/app ### Open website/app
_Supported on:_ :material-android: :material-apple: :material-firefox: _Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -1710,6 +1714,9 @@ And the same example using [JSON publishing](#publish-as-json):
])); ]));
``` ```
The short format for the `view` action is `view, <label>, <url>` (e.g. `view, Open Google, https://google.com`),
but you can always just use the `<key>=<value>` notation as well (e.g. `action=view, url=https://google.com, label=Open Google`).
The `view` action supports the following fields: The `view` action supports the following fields:
| Field | Required | Type | Default | Example | Description | | Field | Required | Type | Default | Example | Description |
@@ -1986,6 +1993,9 @@ And the same example using [JSON publishing](#publish-as-json):
])); ]));
``` ```
The short format for the `broadcast` action is `broadcast, <label>, <url>` (e.g. `broadcast, Take picture, extras.cmd=pic`),
but you can always just use the `<key>=<value>` notation as well (e.g. `action=broadcast, label=Take picture, extras.cmd=pic`).
The `broadcast` action supports the following fields: The `broadcast` action supports the following fields:
| Field | Required | Type | Default | Example | Description | | Field | Required | Type | Default | Example | Description |
@@ -2273,6 +2283,9 @@ And the same example using [JSON publishing](#publish-as-json):
])); ]));
``` ```
The short format for the `http` action is `http, <label>, <url>` (e.g. `http, Close door, https://api.mygarage.lan/close`),
but you can always just use the `<key>=<value>` notation as well (e.g. `action=http, label=Close door, url=https://api.mygarage.lan/close`).
The `http` action supports the following fields: The `http` action supports the following fields:
| Field | Required | Type | Default | Example | Description | | Field | Required | Type | Default | Example | Description |
@@ -2285,6 +2298,249 @@ The `http` action supports the following fields:
| `body` | - | *string* | *empty* | `some body, somebody?` | HTTP body | | `body` | - | *string* | *empty* | `some body, somebody?` | HTTP body |
| `clear` | - | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. | | `clear` | - | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. |
### Copy to clipboard
_Supported on:_ :material-android: :material-firefox:
The `copy` action **copies a given value to the clipboard when the action button is tapped**. This is useful for
one-time passcodes, tokens, or any other value you want to quickly copy without opening the full notification.
Here's an example using the [`X-Actions` header](#using-a-header):
=== "Command line (curl)"
```
curl \
-d "Your one-time passcode is 123456" \
-H "Actions: copy, Copy code, 123456" \
ntfy.sh/myhome
```
=== "ntfy CLI"
```
ntfy publish \
--actions="copy, Copy code, 123456" \
myhome \
"Your one-time passcode is 123456"
```
=== "HTTP"
``` http
POST /myhome HTTP/1.1
Host: ntfy.sh
Actions: copy, Copy code, 123456
Your one-time passcode is 123456
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.sh/myhome', {
method: 'POST',
body: 'Your one-time passcode is 123456',
headers: {
'Actions': 'copy, Copy code, 123456'
}
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Your one-time passcode is 123456"))
req.Header.Set("Actions", "copy, Copy code, 123456")
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$Request = @{
Method = "POST"
URI = "https://ntfy.sh/myhome"
Headers = @{
Actions = "copy, Copy code, 123456"
}
Body = "Your one-time passcode is 123456"
}
Invoke-RestMethod @Request
```
=== "Python"
``` python
requests.post("https://ntfy.sh/myhome",
data="Your one-time passcode is 123456",
headers={ "Actions": "copy, Copy code, 123456" })
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/myhome', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' =>
"Content-Type: text/plain\r\n" .
"Actions: copy, Copy code, 123456",
'content' => 'Your one-time passcode is 123456'
]
]));
```
And the same example using [JSON publishing](#publish-as-json):
=== "Command line (curl)"
```
curl ntfy.sh \
-d '{
"topic": "myhome",
"message": "Your one-time passcode is 123456",
"actions": [
{
"action": "copy",
"label": "Copy code",
"value": "123456"
}
]
}'
```
=== "ntfy CLI"
```
ntfy publish \
--actions '[
{
"action": "copy",
"label": "Copy code",
"value": "123456"
}
]' \
myhome \
"Your one-time passcode is 123456"
```
=== "HTTP"
``` http
POST / HTTP/1.1
Host: ntfy.sh
{
"topic": "myhome",
"message": "Your one-time passcode is 123456",
"actions": [
{
"action": "copy",
"label": "Copy code",
"value": "123456"
}
]
}
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.sh', {
method: 'POST',
body: JSON.stringify({
topic: "myhome",
message: "Your one-time passcode is 123456",
actions: [
{
action: "copy",
label: "Copy code",
value: "123456"
}
]
})
})
```
=== "Go"
``` go
// You should probably use json.Marshal() instead and make a proper struct,
// but for the sake of the example, this is easier.
body := `{
"topic": "myhome",
"message": "Your one-time passcode is 123456",
"actions": [
{
"action": "copy",
"label": "Copy code",
"value": "123456"
}
]
}`
req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body))
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$Request = @{
Method = "POST"
URI = "https://ntfy.sh"
Body = ConvertTo-JSON @{
Topic = "myhome"
Message = "Your one-time passcode is 123456"
Actions = @(
@{
Action = "copy"
Label = "Copy code"
Value = "123456"
}
)
}
ContentType = "application/json"
}
Invoke-RestMethod @Request
```
=== "Python"
``` python
requests.post("https://ntfy.sh/",
data=json.dumps({
"topic": "myhome",
"message": "Your one-time passcode is 123456",
"actions": [
{
"action": "copy",
"label": "Copy code",
"value": "123456"
}
]
})
)
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json",
'content' => json_encode([
"topic": "myhome",
"message": "Your one-time passcode is 123456",
"actions": [
[
"action": "copy",
"label": "Copy code",
"value": "123456"
]
]
])
]
]));
```
The short format for the `copy` action is `copy, <label>, <value>` (e.g. `copy, Copy code, 123456`),
but you can always just use the `<key>=<value>` notation as well (e.g. `action=copy, label=Copy code, value=123456`).
The `copy` action supports the following fields:
| Field | Required | Type | Default | Example | Description |
|----------|----------|-----------|---------|-----------------|--------------------------------------------------|
| `action` | ✔️ | *string* | - | `copy` | Action type (**must be `copy`**) |
| `label` | ✔️ | *string* | - | `Copy code` | Label of the action button in the notification |
| `value` | ✔️ | *string* | - | `123456` | Value to copy to the clipboard |
| `clear` | - | *boolean* | `false` | `true` | Clear notification after action button is tapped |
## Scheduled delivery ## Scheduled delivery
_Supported on:_ :material-android: :material-apple: :material-firefox: _Supported on:_ :material-android: :material-apple: :material-firefox:

View File

@@ -1673,6 +1673,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
* Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting) * Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting)
* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582)) * Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582))
* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting) * Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting)
* Support "copy" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
**Bug fixes + maintenance:** **Bug fixes + maintenance:**
@@ -1687,6 +1688,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
* Server: Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting) * Server: Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting)
* Server: Add admin-only `GET /v1/version` endpoint returning server version, build commit, and date ([#1599](https://github.com/binwiederhier/ntfy/issues/1599), thanks to [@crivchri](https://github.com/crivchri) for reporting) * Server: Add admin-only `GET /v1/version` endpoint returning server version, build commit, and date ([#1599](https://github.com/binwiederhier/ntfy/issues/1599), thanks to [@crivchri](https://github.com/crivchri) for reporting)
* Server/Web: Support "copy" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
* Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting) * Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting)
**Bug fixes + maintenance:** **Bug fixes + maintenance:**

View File

@@ -4,10 +4,11 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"heckel.io/ntfy/v2/util"
"regexp" "regexp"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"heckel.io/ntfy/v2/util"
) )
const ( const (
@@ -20,12 +21,14 @@ const (
actionView = "view" actionView = "view"
actionBroadcast = "broadcast" actionBroadcast = "broadcast"
actionHTTP = "http" actionHTTP = "http"
actionCopy = "copy"
) )
var ( var (
actionsAll = []string{actionView, actionBroadcast, actionHTTP} actionsAll = []string{actionView, actionBroadcast, actionHTTP, actionCopy}
actionsWithURL = []string{actionView, actionHTTP} actionsWithURL = []string{actionView, actionHTTP} // Must be distinct from actionsWithValue, see populateAction()
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`) actionsWithValue = []string{actionCopy} // Must be distinct from actionsWithURL, see populateAction()
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
) )
type actionParser struct { type actionParser struct {
@@ -61,11 +64,13 @@ func parseActions(s string) (actions []*action, err error) {
} }
for _, action := range actions { for _, action := range actions {
if !util.Contains(actionsAll, action.Action) { if !util.Contains(actionsAll, action.Action) {
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action) return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast', 'http' and 'copy'", action.Action)
} else if action.Label == "" { } else if action.Label == "" {
return nil, fmt.Errorf("parameter 'label' is required") return nil, fmt.Errorf("parameter 'label' is required")
} else if util.Contains(actionsWithURL, action.Action) && action.URL == "" { } else if util.Contains(actionsWithURL, action.Action) && action.URL == "" {
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action) return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
} else if util.Contains(actionsWithValue, action.Action) && action.Value == "" {
return nil, fmt.Errorf("parameter 'value' is required for action '%s'", action.Action)
} else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" { } else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method) return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
} }
@@ -158,6 +163,8 @@ func populateAction(newAction *action, section int, key, value string) error {
key = "label" key = "label"
} else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) { } else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) {
key = "url" key = "url"
} else if key == "" && section == 2 && util.Contains(actionsWithValue, newAction.Action) {
key = "value"
} }
// Validate // Validate
@@ -188,6 +195,8 @@ func populateAction(newAction *action, section int, key, value string) error {
newAction.Method = value newAction.Method = value
case "body": case "body":
newAction.Body = value newAction.Body = value
case "value":
newAction.Value = value
case "intent": case "intent":
newAction.Intent = value newAction.Intent = value
default: default:

View File

@@ -1,8 +1,9 @@
package server package server
import ( import (
"github.com/stretchr/testify/require"
"testing" "testing"
"github.com/stretchr/testify/require"
) )
func TestParseActions(t *testing.T) { func TestParseActions(t *testing.T) {
@@ -132,6 +133,44 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `https://x.org`, actions[1].URL) require.Equal(t, `https://x.org`, actions[1].URL)
require.Equal(t, true, actions[1].Clear) require.Equal(t, true, actions[1].Clear)
// Copy action (simple format)
actions, err = parseActions("copy, Copy code, 1234")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
require.Equal(t, "Copy code", actions[0].Label)
require.Equal(t, "1234", actions[0].Value)
// Copy action (JSON)
actions, err = parseActions(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
require.Equal(t, "Copy OTP", actions[0].Label)
require.Equal(t, "567890", actions[0].Value)
// Copy action with clear
actions, err = parseActions("copy, Copy code, 1234, clear=true")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
require.Equal(t, "Copy code", actions[0].Label)
require.Equal(t, "1234", actions[0].Value)
require.Equal(t, true, actions[0].Clear)
// Copy action with explicit value key
actions, err = parseActions("action=copy, label=Copy token, clear=true, value=abc-123-def")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
require.Equal(t, "Copy token", actions[0].Label)
require.Equal(t, "abc-123-def", actions[0].Value)
require.True(t, actions[0].Clear)
// Copy action without value (error)
_, err = parseActions("copy, Copy code")
require.EqualError(t, err, "parameter 'value' is required for action 'copy'")
// Invalid syntax // Invalid syntax
_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`) _, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
require.EqualError(t, err, "unexpected character 'x' at position 22") require.EqualError(t, err, "unexpected character 'x' at position 22")
@@ -146,7 +185,7 @@ func TestParseActions(t *testing.T) {
require.EqualError(t, err, "term 'what is this anyway' unknown") require.EqualError(t, err, "term 'what is this anyway' unknown")
_, err = parseActions(`fdsfdsf`) _, err = parseActions(`fdsfdsf`)
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast' and 'http'") require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast', 'http' and 'copy'")
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`) _, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
require.EqualError(t, err, "key 'aaa' unknown") require.EqualError(t, err, "key 'aaa' unknown")
@@ -173,7 +212,7 @@ func TestParseActions(t *testing.T) {
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value") require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
_, err = parseActions(`[ { "some": "object" } ]`) _, err = parseActions(`[ { "some": "object" } ]`)
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast' and 'http'") require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast', 'http' and 'copy'")
_, err = parseActions("\x00\x01\xFFx\xFE") _, err = parseActions("\x00\x01\xFFx\xFE")
require.EqualError(t, err, "invalid utf-8 string") require.EqualError(t, err, "invalid utf-8 string")

View File

@@ -86,7 +86,7 @@ type attachment struct {
type action struct { type action struct {
ID string `json:"id"` ID string `json:"id"`
Action string `json:"action"` // "view", "broadcast", or "http" Action string `json:"action"` // "view", "broadcast", "http", or "copy"
Label string `json:"label"` // action button label Label string `json:"label"` // action button label
Clear bool `json:"clear"` // clear notification after successful execution Clear bool `json:"clear"` // clear notification after successful execution
URL string `json:"url,omitempty"` // used in "view" and "http" actions URL string `json:"url,omitempty"` // used in "view" and "http" actions
@@ -95,6 +95,7 @@ type action struct {
Body string `json:"body,omitempty"` // used in "http" action Body string `json:"body,omitempty"` // used in "http" action
Intent string `json:"intent,omitempty"` // used in "broadcast" action Intent string `json:"intent,omitempty"` // used in "broadcast" action
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
Value string `json:"value,omitempty"` // used in "copy" action
} }
func newAction() *action { func newAction() *action {

View File

@@ -4,6 +4,7 @@ import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies"; import { NetworkFirst } from "workbox-strategies";
import { clientsClaim } from "workbox-core"; import { clientsClaim } from "workbox-core";
import { dbAsync } from "../src/app/db"; import { dbAsync } from "../src/app/db";
import { ACTION_COPY, ACTION_HTTP, ACTION_VIEW } from "../src/app/actions";
import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils"; import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
import initI18n from "../src/app/i18n"; import initI18n from "../src/app/i18n";
import { import {
@@ -250,12 +251,32 @@ const handleClick = async (event) => {
} }
}; };
if (action.action === "view") { if (action.action === ACTION_VIEW) {
self.clients.openWindow(action.url); self.clients.openWindow(action.url);
if (action.clear) { if (action.clear) {
await clearNotification(); await clearNotification();
} }
} else if (action.action === "http") { } else if (action.action === ACTION_COPY) {
try {
// Service worker can't access the clipboard API directly, so we try to
// open a focused client and use it, or fall back to opening a window
const allClients = await self.clients.matchAll({ type: "window" });
const focusedClient = allClients.find((c) => c.focused) || allClients[0];
if (focusedClient) {
focusedClient.postMessage({ type: "copy", value: action.value });
}
if (action.clear) {
await clearNotification();
}
} catch (e) {
console.error("[ServiceWorker] Error performing copy action", e);
self.registration.showNotification(`${t("notifications_actions_failed_notification")}: ${action.label} (${action.action})`, {
body: e.message,
icon,
badge,
});
}
} else if (action.action === ACTION_HTTP) {
try { try {
const response = await fetch(action.url, { const response = await fetch(action.url, {
method: action.method ?? "POST", method: action.method ?? "POST",

View File

@@ -2,6 +2,7 @@
// and cannot be used in the service worker // and cannot be used in the service worker
import emojisMapped from "./emojisMapped"; import emojisMapped from "./emojisMapped";
import { ACTION_COPY, ACTION_HTTP, ACTION_VIEW } from "./actions";
const toEmojis = (tags) => { const toEmojis = (tags) => {
if (!tags) return []; if (!tags) return [];
@@ -81,7 +82,7 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute, baseUr
topicRoute, topicRoute,
}, },
actions: message.actions actions: message.actions
?.filter(({ action }) => action === "view" || action === "http") ?.filter(({ action }) => action === ACTION_VIEW || action === ACTION_HTTP || action === ACTION_COPY)
.map(({ label }) => ({ .map(({ label }) => ({
action: label, action: label,
title: label, title: label,

View File

@@ -36,6 +36,7 @@ import {
topicUrl, topicUrl,
unmatchedTags, unmatchedTags,
} from "../app/utils"; } from "../app/utils";
import { ACTION_BROADCAST, ACTION_COPY, ACTION_HTTP, ACTION_VIEW } from "../app/actions";
import { formatMessage, formatTitle, isImage } from "../app/notificationUtils"; import { formatMessage, formatTitle, isImage } from "../app/notificationUtils";
import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles"; import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
@@ -345,7 +346,7 @@ const NotificationItem = (props) => {
</Tooltip> </Tooltip>
</> </>
)} )}
{hasUserActions && <UserActions notification={notification} />} {hasUserActions && <UserActions notification={notification} onShowSnack={props.onShowSnack} />}
</CardActions> </CardActions>
)} )}
</Card> </Card>
@@ -487,7 +488,7 @@ const Image = (props) => {
const UserActions = (props) => ( const UserActions = (props) => (
<> <>
{props.notification.actions.map((action) => ( {props.notification.actions.map((action) => (
<UserAction key={action.id} notification={props.notification} action={action} /> <UserAction key={action.id} notification={props.notification} action={action} onShowSnack={props.onShowSnack} />
))} ))}
</> </>
); );
@@ -549,7 +550,7 @@ const UserAction = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { notification } = props; const { notification } = props;
const { action } = props; const { action } = props;
if (action.action === "broadcast") { if (action.action === ACTION_BROADCAST) {
return ( return (
<Tooltip title={t("notifications_actions_not_supported")}> <Tooltip title={t("notifications_actions_not_supported")}>
<span> <span>
@@ -560,7 +561,7 @@ const UserAction = (props) => {
</Tooltip> </Tooltip>
); );
} }
if (action.action === "view") { if (action.action === ACTION_VIEW) {
const handleClick = () => { const handleClick = () => {
openUrl(action.url); openUrl(action.url);
if (action.clear) { if (action.clear) {
@@ -580,7 +581,7 @@ const UserAction = (props) => {
</Tooltip> </Tooltip>
); );
} }
if (action.action === "http") { if (action.action === ACTION_HTTP) {
const method = action.method ?? "POST"; const method = action.method ?? "POST";
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? ""); const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
return ( return (
@@ -602,6 +603,22 @@ const UserAction = (props) => {
</Tooltip> </Tooltip>
); );
} }
if (action.action === ACTION_COPY) {
const handleClick = async () => {
await copyToClipboard(action.value);
props.onShowSnack();
if (action.clear) {
await clearNotification(notification);
}
};
return (
<Tooltip title={t("common_copy_to_clipboard")}>
<Button onClick={handleClick} aria-label={t("common_copy_to_clipboard")}>
{action.label}
</Button>
</Tooltip>
);
}
return null; // Others return null; // Others
}; };

View File

@@ -12,6 +12,15 @@ const registerSW = () => {
return; return;
} }
// Listen for messages from the service worker (e.g., "copy" action)
navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data?.type === "copy" && event.data?.value) {
navigator.clipboard?.writeText(event.data.value).catch((e) => {
console.error("[ServiceWorker] Failed to copy to clipboard", e);
});
}
});
viteRegisterSW({ viteRegisterSW({
onRegisteredSW(swUrl, registration) { onRegisteredSW(swUrl, registration) {
console.log("[ServiceWorker] Registered:", { swUrl, registration }); console.log("[ServiceWorker] Registered:", { swUrl, registration });