Files
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

402 lines
14 KiB
TypeScript

'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<string, string>
}) {
const [nachrichten, setNachrichten] = useState(initial)
const [reaktionen, setReaktionen] = useState(initialReactions)
const [text, setText] = useState('')
const [antwortAuf, setAntwortAuf] = useState<string | null>(null)
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
const [entwurf, setEntwurf] = useState('')
const [fehler, setFehler] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
const ende = useRef<HTMLDivElement>(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 (
<div className="border border-line">
<div className="border-b border-line px-4 py-2">
<p className="font-condensed text-xs text-text-dim">{chatText.heading}</p>
<p className="mt-0.5 text-2xs text-text-dim">{chatText.hint}</p>
</div>
<ol className="max-h-[28rem] space-y-4 overflow-y-auto px-4 py-4">
{nachrichten.length === 0 ? (
<li className="text-text-dim">{chatText.empty}</li>
) : null}
{nachrichten.map((nachricht, index) => {
const vorherige = nachrichten[index - 1]
const neuAbHier =
grenze !== null &&
nachricht.createdAt > grenze &&
(!vorherige || vorherige.createdAt <= grenze)
return (
<li key={nachricht.id}>
{neuAbHier ? (
<p className="mb-3 flex items-center gap-3 font-condensed text-2xs text-stamp-3">
<span className="h-px grow bg-stamp-3" />
{chatText.unreadDivider}
<span className="h-px grow bg-stamp-3" />
</p>
) : null}
{nachricht.replyToId ? (
<p className="mb-1 border-l-(length:--spacing-edge) border-line pl-2 text-2xs text-text-dim">
{chatText.replyingTo} {nachId.get(nachricht.replyToId)?.authorName ?? '-'}:{' '}
{(nachId.get(nachricht.replyToId)?.body ?? '').slice(0, 80)}
</p>
) : null}
<div className="flex flex-wrap items-baseline gap-x-3 text-2xs text-text-dim">
<span className="font-condensed">{nachricht.authorName}</span>
<time dateTime={nachricht.createdAt}>
{new Date(nachricht.createdAt).toLocaleString('de-DE')}
</time>
{nachricht.editedAt ? <span>{chatText.edited}</span> : null}
{nachricht.adoptedEntryId ? (
<span className="font-condensed text-accent">{chatText.adopted}</span>
) : null}
</div>
{nachricht.withdrawnAt ? (
<p className="mt-0.5 text-sm text-text-dim italic">{chatText.withdrawn}</p>
) : bearbeitet === nachricht.id ? (
<div className="mt-1">
<textarea
value={entwurf}
onChange={(event) => setEntwurf(event.target.value)}
rows={2}
className="block w-full field"
/>
<div className="mt-1 flex gap-3">
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const ergebnis = await editMessageAction({
id: nachricht.id,
body: entwurf,
})
if (ergebnis.ok) setBearbeitet(null)
else setFehler(ergebnis.error)
})
}
className="btn btn-sm btn-primary"
>
{chatText.editSave}
</button>
<button
type="button"
onClick={() => setBearbeitet(null)}
className="btn btn-sm"
>
{commonText.cancel}
</button>
</div>
</div>
) : (
<div className="mt-0.5">
<MessageBody
body={nachricht.body}
mentionNames={namen}
previews={new Map(Object.entries(nachricht.previews))}
/>
</div>
)}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs">
{REACTIONS.map((key) => {
const anzahl = reaktionen.filter(
(r) => r.messageId === nachricht.id && r.key === key,
).length
const eigene = reaktionen.some(
(r) => r.messageId === nachricht.id && r.key === key && r.userId === currentUserId,
)
return (
<button
key={key}
type="button"
title={chatText.reactions[key]}
onClick={() =>
startTransition(async () => {
// Sofort umschalten, der Server bestätigt nur.
setReaktionen((bisher) =>
eigene
? bisher.filter(
(r) =>
!(r.messageId === nachricht.id && r.key === key && r.userId === currentUserId),
)
: [...bisher, { messageId: nachricht.id, userId: currentUserId, key }],
)
await toggleReactionAction({ id: nachricht.id, key })
})
}
aria-pressed={eigene}
className="btn btn-sm font-mono"
>
{chatText.reactionSymbols[key]}
{anzahl > 0 ? ` ${anzahl}` : ''}
</button>
)
})}
{!nachricht.withdrawnAt ? (
<button
type="button"
onClick={() => setAntwortAuf(nachricht.id)}
className="btn btn-sm"
>
{chatText.reply}
</button>
) : null}
{/* Die Brücke zum Aktenbestand steht sichtbar an jeder
Nachricht, nicht in einem Kontextmenü. Siehe docs/06. */}
{canAdopt && !nachricht.withdrawnAt && !nachricht.adoptedEntryId ? (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const ergebnis = await adoptAsEntryAction({ id: nachricht.id })
if (!ergebnis.ok) setFehler(ergebnis.error)
})
}
className="btn btn-sm btn-primary"
>
{chatText.adopt}
</button>
) : null}
{nachricht.authorId === currentUserId && !nachricht.withdrawnAt ? (
<>
<button
type="button"
onClick={() => {
setBearbeitet(nachricht.id)
setEntwurf(nachricht.body)
}}
className="btn btn-sm"
>
{chatText.edit}
</button>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
await withdrawMessageAction({ id: nachricht.id })
})
}
className="btn btn-sm"
>
{chatText.withdraw}
</button>
</>
) : null}
</div>
</li>
)
})}
<div ref={ende} />
</ol>
<div className="border-t border-line px-4 py-3">
{antwortAuf ? (
<p className="mb-2 flex items-center gap-3 text-2xs text-text-dim">
{chatText.replyingTo} {nachId.get(antwortAuf)?.authorName ?? '-'}
<button
type="button"
onClick={() => setAntwortAuf(null)}
className="btn btn-sm"
>
{chatText.cancelReply}
</button>
</p>
) : null}
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
// Eingabetaste sendet, Umschalt+Eingabe macht einen Umbruch.
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
senden()
}
}}
rows={2}
placeholder={chatText.placeholder}
aria-label={chatText.placeholder}
className="block w-full field"
/>
<div className="mt-2 flex items-center gap-3">
<button
type="button"
onClick={senden}
disabled={text.trim() === ''}
className="btn btn-sm btn-primary"
>
{chatText.send}
</button>
{fehler ? <span className="font-condensed text-2xs text-stamp-4">{fehler}</span> : null}
</div>
</div>
</div>
)
}