From 8cabd17cdd34e29d5d8c338e9d2812aad16fe719 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sat, 27 Jun 2026 21:33:31 +0300 Subject: [PATCH] frontend: rebuild the messages thread with Message/Bubble/Attachment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/messages/messages-view.tsx | 195 ++++++++-------- frontend/components/ui/attachment.tsx | 209 ++++++++++++++++++ frontend/components/ui/bubble.tsx | 128 +++++++++++ frontend/components/ui/message.tsx | 92 ++++++++ 4 files changed, 534 insertions(+), 90 deletions(-) create mode 100644 frontend/components/ui/attachment.tsx create mode 100644 frontend/components/ui/bubble.tsx create mode 100644 frontend/components/ui/message.tsx diff --git a/frontend/components/messages/messages-view.tsx b/frontend/components/messages/messages-view.tsx index 444cfc0..ea07eff 100644 --- a/frontend/components/messages/messages-view.tsx +++ b/frontend/components/messages/messages-view.tsx @@ -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 ( - + + + router.push( + `/settings?tab=careTeam&member=${encodeURIComponent(att.userId)}`, + ) + } + /> + + + + + + {t("messages.system.passwordResetTitle")} + + + {t("messages.system.passwordResetBody", { name: att.userName })} + + + ); } + if (att.kind === "file") { return ( - + + + + + + {att.fileName} + + + { + void downloadAttachment(att.attachmentId, att.fileName).catch( + () => { + /* ignore — surfaced by the browser */ + }, + ); + }} + > + + + + ); } + const a = att.appointment; return ( <> - + + setApptOpen(true)} + /> + + + + + {a.name} + + {[a.date, a.time, a.type, a.provider].filter(Boolean).join(" · ")} + + + )} -
- {selected.isGroup && !out && startsGroup && ( - - {m.senderName} - - )} - {m.body && ( -
- {m.body} -
- )} - {m.attachments?.map((att, ai) => ( - - ))} - {endsGroup && ( - - {formatTime(m.createdAt)} - - )} -
+ + {selected.isGroup && !out && startsGroup && ( + {m.senderName} + )} + {m.body && ( + + {m.body} + + )} + {m.attachments?.map((att, ai) => ( + + ))} + {endsGroup && ( + + {formatTime(m.createdAt)} + + )} + + ); })} diff --git a/frontend/components/ui/attachment.tsx b/frontend/components/ui/attachment.tsx new file mode 100644 index 0000000..5fa79cb --- /dev/null +++ b/frontend/components/ui/attachment.tsx @@ -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 & { + state?: "idle" | "uploading" | "processing" | "error" | "done" + }) { + const resolvedOrientation = orientation ?? "horizontal" + + return ( +
+ ) +} + +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) { + return ( +
+ ) +} + +function AttachmentContent({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AttachmentTitle({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function AttachmentDescription({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function AttachmentActions({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AttachmentAction({ + className, + variant, + size = "icon-xs", + ...props +}: React.ComponentProps) { + return ( +