'use client' import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react' import { uuidv7 } from '@/lib/uuidv7' import { chatText } from '@/lib/i18n/chat' import { commonText } from '@/lib/i18n/common' import { REACTIONS } from '@/lib/chat/terms' import { useStream } from '@/components/realtime/useStream' import { MessageBody } from './MessageBody' import { adoptAsEntryAction, editMessageAction, markReadAction, sendMessageAction, toggleReactionAction, withdrawMessageAction, } from '@/app/(app)/chat/actions' import type { ChatMessageView } from '@/lib/chat/view' export type ChatScopeView = | { kind: 'case'; caseNumber: string; caseId: string } | { kind: 'personnel'; fileId: string } | { kind: 'department'; departmentId: string } type Reaktion = { messageId: string; userId: string; key: string } /** * Der Chat einer Akte - Fallakte oder Personalakte. * * Optisch klar vom Verlauf abgesetzt, weil er inhaltlich etwas anderes ist: * formlos, kein Aktenbestandteil. Die Zeitachse des Verlaufs hat eine * durchgehende Linie, der Chat nicht - der Unterschied muss ohne Beschriftung * erkennbar sein. Siehe docs/10-design-system.md. * * Gesendet wird optimistisch: die Nachricht steht sofort da, mit einer * clientseitig erzeugten UUIDv7 als Kennung. Kommt dieselbe Kennung über den * Strom zurück, wird der optimistische Eintrag ersetzt statt verdoppelt - und * ein wiederholtes Absenden nach einem Netzfehler erzeugt keine zweite * Nachricht. Siehe docs/09-performance-und-ux.md. */ export function Chat({ scope, topic, initial, initialReactions, currentUserId, lastReadAt, canAdopt, mentionNames, }: { scope: ChatScopeView topic: string initial: ChatMessageView[] initialReactions: Reaktion[] currentUserId: string lastReadAt: string | null canAdopt: boolean mentionNames: Record }) { const [nachrichten, setNachrichten] = useState(initial) const [reaktionen, setReaktionen] = useState(initialReactions) const [text, setText] = useState('') const [antwortAuf, setAntwortAuf] = useState(null) const [bearbeitet, setBearbeitet] = useState(null) const [entwurf, setEntwurf] = useState('') const [fehler, setFehler] = useState(null) const [pending, startTransition] = useTransition() const ende = useRef(null) // Die Trennlinie friert beim ersten Rendern ein: sie soll nicht // verschwinden, während man liest. const [grenze] = useState(lastReadAt) const namen = useMemo(() => new Map(Object.entries(mentionNames)), [mentionNames]) const eingetroffen = useCallback((data: unknown) => { const nachricht = data as ChatMessageView setNachrichten((bisher) => { const index = bisher.findIndex((n) => n.id === nachricht.id) // Dedupe über die Kennung: der optimistische Eintrag wird ersetzt. if (index >= 0) { const kopie = [...bisher] kopie[index] = nachricht return kopie } return [...bisher, nachricht] }) }, []) useStream([topic], { 'chat.new': eingetroffen, 'chat.edit': eingetroffen, 'chat.withdrawn': eingetroffen, 'chat.adopted': eingetroffen, 'chat.reaction': () => { // Reaktionen sind zu klein für ein eigenes Nutzlastformat; die Ansicht // holt sie beim nächsten Laden nach. }, }) useEffect(() => { ende.current?.scrollIntoView({ block: 'nearest' }) }, [nachrichten.length]) // Beim Verlassen der Seite die Lesemarke setzen, nicht beim Öffnen: sonst // wäre die Trennlinie beim Neuladen sofort weg. useEffect(() => { const scopeId = scope.kind === 'case' ? scope.caseId : scope.kind === 'personnel' ? scope.fileId : scope.departmentId return () => { void markReadAction({ scopeType: scope.kind, scopeId }) } }, [scope]) function senden() { const inhalt = text.trim() if (!inhalt) return const id = uuidv7() const optimistisch: ChatMessageView = { id, body: inhalt, authorId: currentUserId, authorName: namen.get('__self__') ?? commonText.loading, replyToId: antwortAuf, editedAt: null, withdrawnAt: null, adoptedEntryId: null, createdAt: new Date().toISOString(), previews: {}, } setNachrichten((bisher) => [...bisher, optimistisch]) setText('') setAntwortAuf(null) setFehler(null) startTransition(async () => { const ergebnis = await sendMessageAction({ id, scope: scope.kind === 'case' ? { kind: 'case', caseNumber: scope.caseNumber } : scope.kind === 'personnel' ? { kind: 'personnel', fileId: scope.fileId } : { kind: 'department', departmentId: scope.departmentId }, body: inhalt, replyToId: optimistisch.replyToId, }) if (!ergebnis.ok) { // Der optimistische Eintrag verschwindet wieder, der Text kommt // zurück ins Feld. Nichts geht verloren. setNachrichten((bisher) => bisher.filter((n) => n.id !== id)) setText(inhalt) setFehler(ergebnis.error) } }) } const nachId = new Map(nachrichten.map((n) => [n.id, n])) return (

{chatText.heading}

{chatText.hint}

    {nachrichten.length === 0 ? (
  1. {chatText.empty}
  2. ) : null} {nachrichten.map((nachricht, index) => { const vorherige = nachrichten[index - 1] const neuAbHier = grenze !== null && nachricht.createdAt > grenze && (!vorherige || vorherige.createdAt <= grenze) return (
  3. {neuAbHier ? (

    {chatText.unreadDivider}

    ) : null} {nachricht.replyToId ? (

    {chatText.replyingTo} {nachId.get(nachricht.replyToId)?.authorName ?? '-'}:{' '} {(nachId.get(nachricht.replyToId)?.body ?? '').slice(0, 80)}

    ) : null}
    {nachricht.authorName} {nachricht.editedAt ? {chatText.edited} : null} {nachricht.adoptedEntryId ? ( {chatText.adopted} ) : null}
    {nachricht.withdrawnAt ? (

    {chatText.withdrawn}

    ) : bearbeitet === nachricht.id ? (