---
title: ChatMessageActions
description: The replay, edit and copy actions of a user message, revealed when the message is hovered or focused.
---

```tsx
'use client';

import { useState } from 'react';
import { ChatMessageActions } from '@/ui/chat/ChatMessageActions';

export function ChatMessageActionsDemo() {
  const [lastAction, setLastAction] = useState('none');

  return (
    <div className="w-[380px] flex flex-col items-end gap-8">
      {/* The actions are revealed when the parent `group/message` is hovered or focused */}
      <div className="group/message flex flex-col items-end gap-4">
        <p className="text-b3 bg-lightSand rounded-32 px-24 py-12 text-black">
          Hover this message to reveal its actions.
        </p>
        <ChatMessageActions
          onReplay={() => setLastAction('replay')}
          onEdit={() => setLastAction('edit')}
          onCopy={() => setLastAction('copy')}
        />
      </div>
      <p className="text-b4 text-darkGrey">Last action: {lastAction}</p>
    </div>
  );
}
```

## Installation

Using the CLI:

```bash
npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/chat-message-actions.json
```

Or manually, copy the following code into `/ui/chat/ChatMessageActions.tsx`:

```tsx
'use client';

import { Icon } from '@clubmed/trident-icons';
import { useEffect, useRef, useState } from 'react';

export interface ChatMessageLabels {
  replay: string;
  edit: string;
  copy: string;
  /** Replaces the copy label while the copy confirmation is shown */
  copied: string;
  /** Cancels the in-place edition of a message */
  cancel: string;
  /** Submits the edited message */
  send: string;
}

export interface ChatMessageActionsProps {
  onReplay?: () => void;
  onEdit?: () => void;
  /** May be async: the confirmation is shown once the returned promise is resolved */
  onCopy?: () => void | Promise<void>;
  labels?: Partial<ChatMessageLabels>;
}

const COPIED_DURATION_MS = 2000;

const BUTTON_CLASSES =
  'flex size-32 items-center justify-center rounded-full text-darkGrey outline-none hover:bg-lightSand hover:text-black focus-visible:ring-4 focus-visible:ring-black';

/** Shows a confirmation for a short time once the copy succeeded. */
function useCopyConfirmation(onCopy?: () => void | Promise<void>) {
  const [copied, setCopied] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);

  useEffect(() => () => clearTimeout(timer.current), []);

  async function copy() {
    try {
      await onCopy?.();
    } catch {
      return;
    }

    setCopied(true);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), COPIED_DURATION_MS);
  }

  return { copied, copy };
}

/**
 * Actions of a user message. They are revealed when the message is hovered or focused (the parent
 * must be a `group/message`), and always visible on devices without hover.
 */
export function ChatMessageActions({
  onReplay,
  onEdit,
  onCopy,
  labels,
}: Readonly<ChatMessageActionsProps>) {
  const {
    replay = 'Replay',
    edit = 'Edit',
    copy = 'Copy',
    copied: copiedLabel = 'Copied',
  } = labels ?? {};
  const { copied, copy: handleCopy } = useCopyConfirmation(onCopy);

  return (
    <div
      role="toolbar"
      aria-label="Message actions"
      className="flex gap-4 opacity-0 transition-opacity duration-250 group-focus-within/message:opacity-100 group-hover/message:opacity-100 [@media(hover:none)]:opacity-100"
    >
      {onReplay && (
        <button
          type="button"
          aria-label={replay}
          title={replay}
          onClick={onReplay}
          className={BUTTON_CLASSES}
        >
          <Icon name="Reload" width="16px" />
        </button>
      )}
      {onEdit && (
        <button
          type="button"
          aria-label={edit}
          title={edit}
          onClick={onEdit}
          className={BUTTON_CLASSES}
        >
          <Icon name="Edit" width="16px" />
        </button>
      )}
      {onCopy && (
        <button
          type="button"
          aria-label={copied ? copiedLabel : copy}
          title={copied ? copiedLabel : copy}
          onClick={handleCopy}
          className={BUTTON_CLASSES}
        >
          <Icon name={copied ? 'CheckDefault' : 'Clipboard'} width="16px" />
        </button>
      )}
    </div>
  );
}
```

## Usage

`ChatMessageActions` is already rendered by [ChatMessage](/docs/components/chat-message#actions) when
you give it `onReplay`, `onEdit` or `onCopy`: you only need this component to build your own message
bubble.

The actions are hidden until the message is hovered or focused, so the **parent must be a
`group/message`** (Tailwind named group). On devices without hover they are always visible.

```tsx showLineNumbers
import { ChatMessageActions } from '@/ui/chat/ChatMessageActions';

export function Component() {
  return (
    <div className="group/message flex flex-col items-end gap-4">
      <p className="text-b3 bg-lightSand rounded-32 px-24 py-12">Hello</p>
      <ChatMessageActions
        onReplay={() => console.log('replay')}
        onEdit={() => console.log('edit')}
        onCopy={() => navigator.clipboard.writeText('Hello')}
      />
    </div>
  );
}
```

## Hide an action

A button is rendered only when its callback is provided, and the toolbar itself is empty (but still
rendered) when none is. Give only the callbacks you support.

```tsx showLineNumbers
<ChatMessageActions onCopy={() => navigator.clipboard.writeText('Hello')} />
```

## Copy confirmation

Once `onCopy` succeeded, the copy icon is replaced by a check for 2 seconds, and its label by
`labels.copied`. `onCopy` may return a promise: the confirmation is shown when it resolves, and
nothing is shown when it rejects.

## Translate the labels

The labels are used as accessible names and tooltips. Each one is optional and falls back to its
English default.

```tsx showLineNumbers
<ChatMessageActions
  onReplay={() => {}}
  onEdit={() => {}}
  onCopy={() => {}}
  labels={{ replay: 'Rejouer', edit: 'Modifier', copy: 'Copier', copied: 'Copié' }}
/>
```

## API Reference

### ChatMessageActions

| Prop       | Type                                | Default     | Description                                                                 |
| ---------- | ----------------------------------- | ----------- | --------------------------------------------------------------------------- |
| `onReplay` | `() => void`                        | `undefined` | Renders the replay button                                                   |
| `onEdit`   | `() => void`                        | `undefined` | Renders the edit button                                                     |
| `onCopy`   | `() => void \| Promise<void>`       | `undefined` | Renders the copy button. A check is shown once it succeeded (or resolved)   |
| `labels`   | `Partial<ChatMessageLabels>`        | English     | Accessible names and tooltips of the buttons                                |

### ChatMessageLabels

| Key      | Default     | Description                                                   |
| -------- | ----------- | ------------------------------------------------------------- |
| `replay` | `"Replay"`  | Label of the replay button                                    |
| `edit`   | `"Edit"`    | Label of the edit button                                      |
| `copy`   | `"Copy"`    | Label of the copy button                                      |
| `copied` | `"Copied"`  | Label of the copy button while the confirmation is shown      |
| `cancel` | `"Cancel"`  | Used by [ChatMessageEditor](/docs/components/chat-message-editor) |
| `send`   | `"Send"`    | Used by [ChatMessageEditor](/docs/components/chat-message-editor) |

## Notes

- Rendered as a `role="toolbar"` labelled "Message actions".
- Revealed with `group-hover/message` and `group-focus-within/message`, so keyboard users reach the
  buttons with <kbd>Tab</kbd>. Always visible on `(hover: none)` devices.
- The icons are `Reload`, `Edit` and `Clipboard` (`CheckDefault` once copied) from `@clubmed/trident-icons`.
