mirror of
https://github.com/vernu/textbee.git
synced 2026-07-30 17:07:46 -04:00
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:
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user