feat: rework the SMS detail modal around chips and per-field copy

The bottom of the modal was a label/value table, and most of it repeated the
header: Direction restated the To/From already shown with its arrow icon, and
Number restated the number in the title. What remained was two facts wrapped
in table furniture.

Those facts are now inline chips: status, device, and the gateway ID in a
mono chip. The redundant rows are gone.

Copy moved from a single footer button to one button per field, next to the
thing it copies: the number in the header, the message body, and the gateway
ID. A lone "Copy text" button left the target to be inferred, and it could
only ever copy one of the three.

Reused the existing shared CopyButton rather than adding another, which also
brought its copied-state feedback and toast. It had no accessible name
though, so every icon-only copy button was announced identically. It now
names itself after what it copies, which is what lets the new test address
them individually.

Also overrode the dialog primitive's mobile text centring in this header: it
left the title on the left and the timestamp centred beneath it.

E2e reads the clipboard after each button and asserts all three return their
own value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
isra el
2026-07-19 00:17:16 +03:00
parent 1999593e07
commit 628e5b09d1
3 changed files with 138 additions and 90 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, type ReactNode } from 'react'
import { useState } from 'react'
import {
Dialog,
DialogContent,
@@ -9,14 +9,8 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
ArrowDownLeft,
ArrowUpRight,
Copy,
MessageSquare,
Reply,
} from 'lucide-react'
import { toast } from '@/hooks/use-toast'
import { ArrowDownLeft, ArrowUpRight, MessageSquare, Reply, Smartphone } from 'lucide-react'
import { CopyButton } from '@/components/shared/copy-button'
import { getStatusBadge } from './utils'
import { messageDate, messageDirection } from './group'
import { toExactLabel } from '@/components/shared/relative-time'
@@ -34,17 +28,9 @@ type SmsDetailsDialogProps = {
fallbackDeviceId?: string
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className='grid grid-cols-[7rem_1fr] gap-x-4 py-1.5'>
<dt className='text-muted-foreground'>{label}</dt>
<dd className='min-w-0 break-words'>{children}</dd>
</div>
)
}
// Ordered by what people open this for: the message itself first, then the
// metadata that explains it.
// few facts that explain it. Copy actions sit next to the thing they copy,
// rather than a single footer button whose target had to be inferred.
export default function SmsDetailsDialog({
message,
open,
@@ -61,19 +47,17 @@ export default function SmsDetailsDialog({
: message.sender || 'Unknown'
const date = messageDate(message)
const composerDeviceId = message.device?._id || fallbackDeviceId
const handleCopyMessage = () => {
if (message?.message) {
navigator.clipboard.writeText(message.message)
toast({ title: 'Message copied to clipboard!' })
}
}
const deviceName = [message.device?.brand, message.device?.model]
.filter(Boolean)
.join(' ')
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[550px]'>
<DialogHeader>
<DialogContent className='sm:max-w-[540px]'>
{/* text-left overrides the primitive's mobile centring, which left
the title on the left and the date centred under it. */}
<DialogHeader className='pr-8 text-left'>
<DialogTitle className='flex items-center gap-2 text-base'>
<span
className={cn(
@@ -93,6 +77,11 @@ export default function SmsDetailsDialog({
<span className='min-w-0 truncate'>
{isSent ? 'To' : 'From'} {counterparty}
</span>
<CopyButton
value={counterparty}
label='Number'
className='h-7 w-7'
/>
</DialogTitle>
{/* Exact time as text: the hover tooltip in the list is not
reachable on touch. */}
@@ -102,40 +91,55 @@ export default function SmsDetailsDialog({
</DialogHeader>
{/* The message body leads: it is the reason this dialog is open. */}
<div className='max-h-56 overflow-y-auto whitespace-pre-wrap break-words rounded-lg bg-muted p-3 text-sm'>
{message.message}
<div className='relative'>
<div className='max-h-56 overflow-y-auto whitespace-pre-wrap break-words rounded-lg bg-muted p-3 pr-11 text-sm'>
{message.message}
</div>
<CopyButton
value={message.message ?? ''}
label='Message'
className='absolute right-1.5 top-1.5 h-7 w-7 bg-background/70 backdrop-blur hover:bg-background'
/>
</div>
<dl className='divide-y divide-border text-sm'>
<Row label='Status'>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
statusBadge.color
)}
>
{statusBadge.icon}
{statusBadge.label}
{/* Facts as chips rather than a label/value table: direction and
number were already in the header, so the table mostly repeated
itself. */}
<div className='flex flex-wrap items-center gap-2 text-xs'>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-1 font-medium',
statusBadge.color
)}
>
{statusBadge.icon}
{statusBadge.label}
</span>
{deviceName && (
<span className='inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-muted-foreground'>
<Smartphone className='h-3 w-3' />
{deviceName}
</span>
</Row>
<Row label='Direction'>{isSent ? 'Sent' : 'Received'}</Row>
<Row label='Number'>{counterparty}</Row>
<Row label='Device'>
{message.device?.brand || message.device?.model
? `${message.device?.brand ?? ''} ${message.device?.model ?? ''}`.trim()
: 'Not recorded'}
</Row>
)}
{message.gatewayMessageId && (
<Row label='Gateway ID'>
<span className='font-mono text-xs'>
<span className='inline-flex min-w-0 items-center gap-1 rounded-full bg-muted py-0.5 pl-2.5 pr-0.5 text-muted-foreground'>
<span className='shrink-0'>ID</span>
<span className='truncate font-mono'>
{message.gatewayMessageId}
</span>
</Row>
<CopyButton
value={message.gatewayMessageId}
label='Gateway ID'
className='h-6 w-6'
/>
</span>
)}
</dl>
</div>
{(message.errorCode || message.errorMessage) && (
<div className='space-y-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3'>
<div className='space-y-1.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3'>
<p className='text-sm font-medium text-destructive'>
Delivery failed
</p>
@@ -152,16 +156,7 @@ export default function SmsDetailsDialog({
</div>
)}
<div className='flex flex-col gap-2 sm:flex-row sm:justify-end'>
<Button
variant='outline'
size='sm'
className='w-full sm:w-auto'
onClick={handleCopyMessage}
>
<Copy className='h-4 w-4' />
Copy text
</Button>
<div className='flex justify-end'>
{isSent ? (
<Button
size='sm'

View File

@@ -1,50 +1,57 @@
"use client";
'use client'
import { Button } from "@/components/ui/button";
import { Check, Copy } from "lucide-react";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { Button } from '@/components/ui/button'
import { Check, Copy } from 'lucide-react'
import { useState } from 'react'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
interface CopyButtonProps {
value: string;
label: string;
className?: string;
value: string
/** What is being copied, e.g. "Message". Names the button for assistive tech. */
label: string
className?: string
}
export function CopyButton({ value, label, className }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const [copied, setCopied] = useState(false)
const { toast } = useToast()
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
await navigator.clipboard.writeText(value)
setCopied(true)
toast({
title: "Copied!",
title: 'Copied!',
description: `${label} copied to clipboard`,
});
setTimeout(() => setCopied(false), 2000);
})
setTimeout(() => setCopied(false), 2000)
} catch (err) {
toast({
title: "Failed to copy",
description: "Please try again",
variant: "destructive",
});
title: 'Failed to copy',
description: 'Please try again',
variant: 'destructive',
})
}
};
}
return (
<Button
variant="ghost"
size="icon"
className={className}
type='button'
variant='ghost'
size='icon'
// Icon-only buttons carried no accessible name, so several of them in
// one view were indistinguishable to a screen reader.
aria-label={`Copy ${label.toLowerCase()}`}
title={`Copy ${label.toLowerCase()}`}
className={cn('shrink-0', className)}
onClick={copyToClipboard}
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
<Check className='h-4 w-4 text-green-600 dark:text-green-400' />
) : (
<Copy className="h-4 w-4" />
<Copy className='h-4 w-4' />
)}
</Button>
);
}
)
}

View File

@@ -187,6 +187,52 @@ test.describe('message history (mocked API, no real backend)', () => {
await expect(dialog.getByText(/\d{4} at \d{1,2}:\d{2}/)).toBeVisible()
})
test('each detail field copies its own value', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.route('**/api/v1/gateway/devices/*/messages*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{
_id: 'copy_1',
recipient: '+15557654321',
message: 'Copy me exactly',
status: 'delivered',
type: 'sent',
gatewayMessageId: '665f1c2a9b1e4a0012ab34cd',
device: { _id: 'device_1', brand: 'Google', model: 'Pixel 8' },
requestedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
},
],
meta: { total: 1, page: 1, limit: 20, totalPages: 1 },
}),
})
)
await page.goto('/dashboard/messaging/history')
await page.getByRole('button', { name: /Copy me exactly/ }).click()
const dialog = page.getByRole('dialog')
const read = () => page.evaluate(() => navigator.clipboard.readText())
// One button per field, each named for what it copies, replacing a single
// footer "Copy text" whose target had to be inferred.
await dialog.getByRole('button', { name: 'Copy number' }).click()
expect(await read()).toBe('+15557654321')
await dialog.getByRole('button', { name: 'Copy message' }).click()
expect(await read()).toBe('Copy me exactly')
await dialog.getByRole('button', { name: 'Copy gateway ID' }).click()
expect(await read()).toBe('665f1c2a9b1e4a0012ab34cd')
})
test('replying from a received message keeps a device selected', async ({
page,
context,