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 (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+export {
+ Attachment,
+ AttachmentGroup,
+ AttachmentMedia,
+ AttachmentContent,
+ AttachmentTitle,
+ AttachmentDescription,
+ AttachmentActions,
+ AttachmentAction,
+ AttachmentTrigger,
+}
diff --git a/frontend/components/ui/bubble.tsx b/frontend/components/ui/bubble.tsx
new file mode 100644
index 0000000..5e7a290
--- /dev/null
+++ b/frontend/components/ui/bubble.tsx
@@ -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 (
+
+ )
+}
+
+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 & {
+ align?: "start" | "end"
+ }) {
+ return (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }
diff --git a/frontend/components/ui/message.tsx b/frontend/components/ui/message.tsx
new file mode 100644
index 0000000..fbc5e9f
--- /dev/null
+++ b/frontend/components/ui/message.tsx
@@ -0,0 +1,92 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function Message({
+ className,
+ align = "start",
+ ...props
+}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
+ return (
+
+ )
+}
+
+function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ MessageGroup,
+ Message,
+ MessageAvatar,
+ MessageContent,
+ MessageFooter,
+ MessageHeader,
+}