Browse Source

Merge branch '1364-copy-action'

binwiederhier 5 days ago
parent
commit
c6ab380ea4

+ 261 - 5
docs/publish.md

@@ -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
   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
+* [`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:
 
@@ -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 
 quoted with double quotes (`"`) or single quotes (`'`) if the value itself contains commas or semicolons. 
 
-The `action=` and `label=` prefix are optional in all actions, and the `url=` prefix is optional in the `view` and 
-`http` action. The only limitation of this format is that depending on your language/library, UTF-8 characters may not 
-work. If they don't, use the [JSON array format](#using-a-json-array) instead.
+Each action type has a short format where some key prefixes can be omitted:
+
+* [`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 
 [`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 
-[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), and [`http` action](#send-http-request) 
-for details.
+[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), [`http` action](#send-http-request),
+and [`copy` action](#copy-to-clipboard) for details.
 
 ### Open website/app
 _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:
 
 | 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:
 
 | 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:
 
 | 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                                                                                                                                               |
 | `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
 _Supported on:_ :material-android: :material-apple: :material-firefox:
 

+ 2 - 0
docs/releases.md

@@ -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)
 * 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)
+* 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:**
 
@@ -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: 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)
 
 **Bug fixes + maintenance:**

+ 14 - 5
server/actions.go

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

+ 42 - 3
server/actions_test.go

@@ -1,8 +1,9 @@
 package server
 
 import (
-	"github.com/stretchr/testify/require"
 	"testing"
+
+	"github.com/stretchr/testify/require"
 )
 
 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, 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
 	_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
 	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")
 
 	_, 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 "`)
 	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")
 
 	_, 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")
 	require.EqualError(t, err, "invalid utf-8 string")

+ 2 - 1
server/types.go

@@ -86,7 +86,7 @@ type attachment struct {
 
 type action struct {
 	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
 	Clear   bool              `json:"clear"`             // clear notification after successful execution
 	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
 	Intent  string            `json:"intent,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 {

+ 23 - 2
web/public/sw.js

@@ -4,6 +4,7 @@ import { NavigationRoute, registerRoute } from "workbox-routing";
 import { NetworkFirst } from "workbox-strategies";
 import { clientsClaim } from "workbox-core";
 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 initI18n from "../src/app/i18n";
 import {
@@ -250,12 +251,32 @@ const handleClick = async (event) => {
         }
       };
 
-      if (action.action === "view") {
+      if (action.action === ACTION_VIEW) {
         self.clients.openWindow(action.url);
         if (action.clear) {
           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 {
           const response = await fetch(action.url, {
             method: action.method ?? "POST",

+ 2 - 1
web/src/app/notificationUtils.js

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

+ 22 - 5
web/src/components/Notifications.jsx

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

+ 9 - 0
web/src/registerSW.js

@@ -12,6 +12,15 @@ const registerSW = () => {
     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({
     onRegisteredSW(swUrl, registration) {
       console.log("[ServiceWorker] Registered:", { swUrl, registration });