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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
402 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|