frontend: rebuild the messages thread with Message/Bubble/Attachment

Compose the conversation thread from the shadcn Message, Bubble, and
Attachment components (Base UI under the hood), keeping COSS semantic colour
tokens — outgoing bubbles use the primary variant, incoming use muted, and
shared files/appointments/password-resets render as Attachment cards. Day
separators, sender grouping, and timestamps are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-27 21:33:31 +03:00
parent 3ee00fcf06
commit 8cabd17cdd
4 changed files with 534 additions and 90 deletions
+105 -90
View File
@@ -27,8 +27,25 @@ import {
import { useTranslation } from "react-i18next";
import { AppointmentDetailDialog } from "@/components/messages/appointment-detail-dialog";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/components/ui/attachment";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
Message,
MessageContent,
MessageFooter,
MessageHeader,
} from "@/components/ui/message";
import {
Dialog,
DialogDescription,
@@ -91,75 +108,85 @@ function sameDay(a: string, b: string): boolean {
// one sender label, one timestamp, tighter spacing.
const GROUP_WINDOW_MS = 5 * 60 * 1000;
// One sent attachment rendered in the thread: a downloadable file chip or a
// shared-appointment card. Alignment (left/right) comes from the parent column.
// One sent attachment rendered in the thread, built on the Attachment primitive:
// a downloadable file, a shared-appointment card, or a password-reset notice.
// Alignment (left/right) is inherited from the parent MessageContent.
function SentAttachment({ att }: { att: MessageAttachment }) {
const { t } = useTranslation();
const router = useRouter();
const [apptOpen, setApptOpen] = useState(false);
if (att.kind === "passwordReset") {
return (
<button
className="max-w-[75%] rounded-2xl border border-warning/40 bg-warning/5 p-3 text-left text-sm transition-colors hover:bg-warning/10"
onClick={() =>
router.push(
`/settings?tab=careTeam&member=${encodeURIComponent(att.userId)}`,
)
}
type="button"
>
<div className="flex items-center gap-1.5 text-warning text-xs">
<KeyRound className="size-3.5" />
{t("messages.system.label")}
</div>
<p className="mt-1 font-medium text-foreground">
{t("messages.system.passwordResetTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("messages.system.passwordResetBody", { name: att.userName })}
</p>
</button>
<Attachment className="max-w-[20rem] border-warning/40 bg-warning/5 hover:bg-warning/10">
<AttachmentTrigger
aria-label={t("messages.system.passwordResetTitle")}
onClick={() =>
router.push(
`/settings?tab=careTeam&member=${encodeURIComponent(att.userId)}`,
)
}
/>
<AttachmentMedia className="bg-warning/15 text-warning">
<KeyRound />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>
{t("messages.system.passwordResetTitle")}
</AttachmentTitle>
<AttachmentDescription>
{t("messages.system.passwordResetBody", { name: att.userName })}
</AttachmentDescription>
</AttachmentContent>
</Attachment>
);
}
if (att.kind === "file") {
return (
<button
className="flex max-w-[75%] items-center gap-2 rounded-2xl border bg-card px-3 py-2 text-left text-foreground text-sm transition-colors hover:bg-accent"
onClick={() => {
void downloadAttachment(att.attachmentId, att.fileName).catch(() => {
/* ignore — surfaced by the browser */
});
}}
type="button"
>
<FileText className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 max-w-48 flex-1 truncate">{att.fileName}</span>
<Download className="size-4 shrink-0 text-muted-foreground" />
</button>
<Attachment className="max-w-[20rem]">
<AttachmentMedia>
<FileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{att.fileName}</AttachmentTitle>
</AttachmentContent>
<AttachmentActions className="pr-1.5">
<AttachmentAction
aria-label={t("messages.attach.download")}
onClick={() => {
void downloadAttachment(att.attachmentId, att.fileName).catch(
() => {
/* ignore — surfaced by the browser */
},
);
}}
>
<Download />
</AttachmentAction>
</AttachmentActions>
</Attachment>
);
}
const a = att.appointment;
return (
<>
<button
className="max-w-[75%] rounded-2xl border bg-card p-3 text-left text-sm transition-colors hover:bg-accent"
onClick={() => setApptOpen(true)}
type="button"
>
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<CalendarClock className="size-3.5" />
{t("messages.attach.apptCardLabel")}
</div>
<p className="mt-1 font-medium text-foreground">{a.name}</p>
<p className="text-muted-foreground text-xs">
{[a.date, a.time].filter(Boolean).join(" · ")}
</p>
{[a.type, a.provider].filter(Boolean).length > 0 && (
<p className="text-muted-foreground text-xs">
{[a.type, a.provider].filter(Boolean).join(" · ")}
</p>
)}
</button>
<Attachment className="max-w-[20rem]">
<AttachmentTrigger
aria-label={t("messages.attach.apptCardLabel")}
onClick={() => setApptOpen(true)}
/>
<AttachmentMedia>
<CalendarClock />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{a.name}</AttachmentTitle>
<AttachmentDescription>
{[a.date, a.time, a.type, a.provider].filter(Boolean).join(" · ")}
</AttachmentDescription>
</AttachmentContent>
</Attachment>
<AppointmentDetailDialog
appointment={a}
onOpenChange={setApptOpen}
@@ -661,46 +688,34 @@ export function MessagesView() {
<div className="h-px flex-1 bg-border" />
</div>
)}
<div
<Message
align={out ? "end" : "start"}
className={cn(
"flex flex-col gap-1",
out ? "items-end" : "items-start",
!newDay && (startsGroup ? "mt-4" : "mt-1"),
)}
>
{selected.isGroup && !out && startsGroup && (
<span className="px-1 text-muted-foreground text-[11px]">
{m.senderName}
</span>
)}
{m.body && (
<div
className={cn(
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
out
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground",
!startsGroup &&
(out ? "rounded-tr-md" : "rounded-tl-md"),
!endsGroup &&
(out ? "rounded-br-md" : "rounded-bl-md"),
)}
>
{m.body}
</div>
)}
{m.attachments?.map((att, ai) => (
<SentAttachment
att={att}
key={`${m.id}-att-${ai}`}
/>
))}
{endsGroup && (
<span className="px-1 text-muted-foreground text-[11px]">
{formatTime(m.createdAt)}
</span>
)}
</div>
<MessageContent>
{selected.isGroup && !out && startsGroup && (
<MessageHeader>{m.senderName}</MessageHeader>
)}
{m.body && (
<Bubble
align={out ? "end" : "start"}
variant={out ? "default" : "muted"}
>
<BubbleContent>{m.body}</BubbleContent>
</Bubble>
)}
{m.attachments?.map((att, ai) => (
<SentAttachment att={att} key={`${m.id}-att-${ai}`} />
))}
{endsGroup && (
<MessageFooter>
{formatTime(m.createdAt)}
</MessageFooter>
)}
</MessageContent>
</Message>
</Fragment>
);
})}
+209
View File
@@ -0,0 +1,209 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-3xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/30 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
{
variants: {
size: {
default:
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
xs: "gap-1.5 rounded-2xl text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
},
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
},
}
)
function Attachment({
className,
state = "done",
size = "default",
orientation = "horizontal",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof attachmentVariants> & {
state?: "idle" | "uploading" | "processing" | "error" | "done"
}) {
const resolvedOrientation = orientation ?? "horizontal"
return (
<div
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={resolvedOrientation}
className={cn(attachmentVariants({ size, orientation }), className)}
{...props}
/>
)
}
const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-xl group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
icon: "",
image:
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
},
},
defaultVariants: {
variant: "icon",
},
}
)
function AttachmentMedia({
className,
variant = "icon",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
return (
<div
data-slot="attachment-media"
data-variant={variant}
className={cn(attachmentMediaVariants({ variant }), className)}
{...props}
/>
)
}
function AttachmentContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className
)}
{...props}
/>
)
}
function AttachmentTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
className
)}
{...props}
/>
)
}
function AttachmentDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
className
)}
{...props}
/>
)
}
function AttachmentActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className
)}
{...props}
/>
)
}
function AttachmentAction({
className,
variant,
size = "icon-xs",
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant={variant ?? "ghost"}
size={size}
className={cn(className)}
{...props}
/>
)
}
function AttachmentTrigger({
className,
render,
type,
...props
}: useRender.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type: render ? type : (type ?? "button"),
className: cn("absolute inset-0 z-10 outline-none", className),
},
props
),
render,
state: {
slot: "attachment-trigger",
},
})
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-group"
className={cn(
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className
)}
{...props}
/>
)
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
}
+128
View File
@@ -0,0 +1,128 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end"
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
}
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-3xl border border-transparent px-3.5 py-2.5 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/30",
className
),
},
props
),
render,
state: {
slot: "bubble-content",
},
})
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
}
)
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end"
side?: "top" | "bottom"
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
function Message({
className,
align = "start",
...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className
)}
{...props}
/>
)
}
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-avatar"
className={cn(
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className
)}
{...props}
/>
)
}
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-content"
className={cn(
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className
)}
{...props}
/>
)
}
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-header"
className={cn(
"flex max-w-full min-w-0 items-center px-3.5 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className
)}
{...props}
/>
)
}
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-footer"
className={cn(
"flex max-w-full min-w-0 items-center px-3.5 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className
)}
{...props}
/>
)
}
export {
MessageGroup,
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
}