← alle entries

conversation

FC-0127Chat & berichtenconversationblockv1.0.0

Gesprek als kaart: kop met portret, naam en status (online, niet storen, offline) en een gespreksmenu; daaronder de berichten als bellen — eigen rechts op --primary, de ander links op --muted — met tijd en op hover/focus een menu per bericht (beantwoorden, kopiëren, verwijderen, melden). Alle acties zijn callbacks. Componeert popover en button; zusje van chat-layout.

zelfde component — andere tokens
Alice de Jong
Online
Hoi! 👋
Hoi Alice, hoe gaat het?
Goed! Heb je de nieuwe registry al gezien?
Nog niet, wat is het?
Een bibliotheek met bouwstenen die je zo in een project zet.
Klinkt goed. Ook blokken voor hele secties?
Ja, en alles op tokens — dus het past meteen bij het merk.

Kom op een bericht voor het menu; het menu rechtsboven is voor het gesprek.

blocks/conversation/conversation.tsx
/**
 * Conversation — een gesprek als kaart: een kop met portret, naam en status
 * (online, niet storen, offline) en een menu voor het gesprek (blokkeren,
 * verwijderen, melden), daaronder de berichten als bellen — eigen rechts op
 * --primary, de ander links op --muted — met tijd en, op hover of focus,
 * een menu per bericht (beantwoorden, kopiëren, verwijderen, melden). Alle
 * acties zijn callbacks; het project praat met zijn backend.
 *
 * Geoogst uit 21st.dev Messaging Conversation (preetsuthar17): radix
 * DropdownMenu/Avatar/ScrollArea/Card → popover (FC-0007), button
 * (FC-0003) en initialen; bg-green-500/red-500/gray-400 → --success/
 * --danger/--muted-foreground, text-rose-600/yellow-600 → --danger/
 * --warning; de vaste 'hextaui'-berichten → messages van het project.
 * Zusje van chat-layout (FC-0059): dat is de driedelige indeling, dit het
 * gesprek zelf.
 */

"use client";

import { useState } from "react";
import type * as React from "react";
import { Button } from "../../primitives/ui/button";
import { Popover } from "../../primitives/ui/popover";
import { usePress } from "../../primitives/ui/press";

export type PresenceStatus = "online" | "dnd" | "offline";
export interface ConversationUser {
  id: string;
  name: string;
  avatar?: string;
  status?: PresenceStatus;
}
export interface ConversationMessage {
  id: string | number;
  text: React.ReactNode;
  senderId: string;
  time: string;
}
export interface ConversationProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onCopy"> {
  me: ConversationUser;
  other: ConversationUser;
  messages: readonly ConversationMessage[];
  onReply?: (m: ConversationMessage) => void;
  onCopy?: (m: ConversationMessage) => void;
  onDelete?: (m: ConversationMessage) => void;
  onReport?: (m: ConversationMessage) => void;
  onBlockUser?: () => void;
  onDeleteConversation?: () => void;
  onReportUser?: () => void;
  height?: string;
  labels?: Partial<Record<"online" | "dnd" | "offline" | "userActions" | "messageActions" | "reply" | "copy" | "delete" | "report" | "block" | "deleteConversation" | "reportUser", string>>;
}

const initialen = (n: string) => n.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? "").join("");
const Icoon = ({ d }: { d: string }) => <svg aria-hidden="true" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d={d} /></svg>;

function Portret({ u, klein }: { u: ConversationUser; klein?: boolean }) {
  return u.avatar ? (
    // eslint-disable-next-line @next/next/no-img-element -- portretten van het project
    <img className={klein ? "fp-conv__portret fp-conv__portret--klein" : "fp-conv__portret"} src={u.avatar} alt="" />
  ) : (
    <span className={klein ? "fp-conv__portret fp-conv__portret--klein fp-conv__portret--letters" : "fp-conv__portret fp-conv__portret--letters"} aria-hidden="true">{initialen(u.name)}</span>
  );
}

function MenuItem({ label, icoon, tone, onPress }: { label: string; icoon: string; tone?: "danger" | "warning"; onPress?: () => void }) {
  const { pressed, pressProps } = usePress({ onPress });
  return (
    <button {...pressProps} type="button" role="menuitem" className="fp-conv__menu-item" data-tone={tone} data-pressed={pressed ? "" : undefined}>
      <Icoon d={icoon} />
      <span>{label}</span>
    </button>
  );
}

function Menu({ label, klein, items }: { label: string; klein?: boolean; items: { label: string; icoon: string; tone?: "danger" | "warning"; onPress?: () => void }[] }) {
  const [open, setOpen] = useState(false);
  return (
    <Popover
      open={open}
      onOpenChange={setOpen}
      placement="bottom-end"
      aria-label={label}
      anchor={
        <Button variant="ghost" aria-label={label} className={klein ? "fp-conv__menuknop fp-conv__menuknop--klein" : "fp-conv__menuknop"} onPress={() => setOpen((o) => !o)}>
          <Icoon d={klein ? "M5 12h.01M12 12h.01M19 12h.01" : "M12 5h.01M12 12h.01M12 19h.01"} />
        </Button>
      }
    >
      <div role="menu" className="fp-conv__menu" onClick={() => setOpen(false)}>
        {items.map((it) => (
          <MenuItem key={it.label} {...it} />
        ))}
      </div>
    </Popover>
  );
}

export function Conversation({ me, other, messages, onReply, onCopy, onDelete, onReport, onBlockUser, onDeleteConversation, onReportUser, height = "32rem", labels, className, style, ...rest }: ConversationProps) {
  const t = { online: "Online", dnd: "Niet storen", offline: "Offline", userActions: "Gespreksacties", messageActions: "Berichtacties", reply: "Beantwoorden", copy: "Kopiëren", delete: "Verwijderen", report: "Melden", block: "Gebruiker blokkeren", deleteConversation: "Gesprek verwijderen", reportUser: "Gebruiker melden", ...labels };
  const status = other.status ?? "offline";
  return (
    <div {...rest} className={["fp-conv", className].filter(Boolean).join(" ")} style={{ ...style, ["--fp-conv-hoogte" as string]: height } as React.CSSProperties}>
      <header className="fp-conv__kop">
        <div className="fp-conv__wie">
          <Portret u={other} />
          <div>
            <div className="fp-conv__naam">{other.name}</div>
            <div className="fp-conv__status"><span className="fp-conv__stip" data-status={status} aria-hidden="true" />{t[status]}</div>
          </div>
        </div>
        <Menu
          label={t.userActions}
          items={[
            { label: t.block, icoon: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM17 8l5 5M22 8l-5 5", tone: "danger", onPress: onBlockUser },
            { label: t.deleteConversation, icoon: "M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14", tone: "danger", onPress: onDeleteConversation },
            { label: t.reportUser, icoon: "M4 22V4h12l-1 4 1 4H4", tone: "warning", onPress: onReportUser },
          ]}
        />
      </header>
      <div className="fp-conv__log" role="log" aria-label="Gesprek">
        {messages.map((m) => {
          const eigen = m.senderId === me.id;
          const u = eigen ? me : other;
          return (
            <div key={m.id} className="fp-conv__bericht" data-eigen={eigen ? "" : undefined}>
              <Portret u={u} klein />
              <div className="fp-conv__inhoud">
                <div className="fp-conv__bel">{m.text}</div>
                <div className="fp-conv__meta">
                  <time className="fp-conv__tijd" dateTime={m.time}>{m.time}</time>
                  <span className="fp-conv__acties">
                    <Menu
                      label={t.messageActions}
                      klein
                      items={[
                        { label: t.reply, icoon: "M9 17l-5-5 5-5M4 12h11a5 5 0 0 1 5 5v2", onPress: () => onReply?.(m) },
                        { label: t.copy, icoon: "M8 8h12v12H8zM4 16V4h12", onPress: () => onCopy?.(m) },
                        ...(eigen ? [{ label: t.delete, icoon: "M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14", tone: "danger" as const, onPress: () => onDelete?.(m) }] : []),
                        { label: t.report, icoon: "M4 22V4h12l-1 4 1 4H4", tone: "warning", onPress: () => onReport?.(m) },
                      ]}
                    />
                  </span>
                </div>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}
blocks/conversation/conversation.css
/** Conversation — importeren in de app-root (registry-site: app/globals.css). */

.fp-conv { display: flex; flex-direction: column; width: 100%; max-width: 42rem; height: var(--fp-conv-hoogte, 32rem); min-height: 0; overflow: hidden; border-radius: calc(var(--radius) * 1.5); border: 1px solid var(--border); background: var(--card); color: var(--card-foreground, var(--foreground)); }
.fp-conv__kop { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0.5rem 1rem; border-bottom: 1px solid var(--border); background: var(--background); }
.fp-conv__wie { display: flex; align-items: center; gap: 0.75rem; }
.fp-conv__portret { width: 2.5rem; height: 2.5rem; flex: 0 0 auto; border-radius: 50%; object-fit: cover; background: var(--muted); }
.fp-conv__portret--klein { width: 2rem; height: 2rem; }
.fp-conv__portret--letters { display: inline-flex; align-items: center; justify-content: center; font: inherit; font-size: 0.75rem; font-weight: 600; color: var(--muted-foreground); }
.fp-conv__naam { font: inherit; font-size: 1rem; font-weight: 600; }
.fp-conv__status { display: flex; align-items: center; gap: 0.375rem; font: inherit; font-size: 0.75rem; color: var(--muted-foreground); }
.fp-conv__stip { display: inline-block; width: 0.625rem; height: 0.625rem; border-radius: 50%; border: 2px solid var(--background); box-sizing: content-box; background: var(--muted-foreground); }
.fp-conv__stip[data-status="online"] { background: var(--success); }
.fp-conv__stip[data-status="dnd"] { background: var(--danger); }
.fp-conv__menuknop { padding: 0; width: 2.25rem; height: 2.25rem; justify-content: center; border: 1px solid color-mix(in srgb, var(--muted-foreground) 30%, transparent); }
.fp-conv__menuknop--klein { width: 1.75rem; height: 1.75rem; border: 0; font-size: 0.75rem; }
.fp-conv__menu { display: flex; flex-direction: column; gap: 0.25rem; min-width: 10rem; padding: 0.25rem; }
.fp-conv__menu-item { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.375rem 0.5rem; appearance: none; border: 0; border-radius: var(--radius); background: transparent; color: inherit; font: inherit; font-size: 0.75rem; font-weight: 500; text-align: left; cursor: pointer; transition: background-color var(--motion-instant-duration) var(--motion-instant); }
.fp-conv__menu-item:hover { background: var(--muted); }
.fp-conv__menu-item[data-pressed] { background: color-mix(in srgb, var(--foreground) 12%, transparent); }
.fp-conv__menu-item[data-tone="danger"] { color: var(--danger); }
.fp-conv__menu-item[data-tone="warning"] { color: var(--warning); }
.fp-conv__menu-item:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: -2px; }
.fp-conv__log { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 1rem; display: flex; flex-direction: column; gap: 1rem; }
.fp-conv__bericht { display: flex; gap: 0.5rem; max-width: 80%; align-self: flex-start; }
.fp-conv__bericht[data-eigen] { align-self: flex-end; flex-direction: row-reverse; }
.fp-conv__inhoud { display: flex; flex-direction: column; min-width: 0; }
.fp-conv__bericht[data-eigen] .fp-conv__inhoud { align-items: flex-end; }
.fp-conv__bel { padding: 0.5rem 0.75rem; border-radius: var(--radius); font: inherit; font-size: 0.875rem; line-height: 1.5; background: var(--muted); color: var(--foreground); }
.fp-conv__bericht[data-eigen] .fp-conv__bel { background: var(--primary); color: var(--primary-foreground); }
.fp-conv__meta { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.25rem; }
.fp-conv__bericht[data-eigen] .fp-conv__meta { flex-direction: row-reverse; }
.fp-conv__tijd { font: inherit; font-size: 0.75rem; color: var(--muted-foreground); }
.fp-conv__acties { opacity: 0; transition: opacity var(--motion-instant-duration) var(--motion-instant); }
.fp-conv__bericht:hover .fp-conv__acties, .fp-conv__bericht:focus-within .fp-conv__acties { opacity: 1; }