Files
Webklar-Kundenbereich/public/neu/ansicht-chat.js
T
ClaudeandClaude Sonnet 5.5 8bb02f3949 Kundenansicht neu E-Fix M5: Chat hält die Eingabe über der Tastatur (revealChatComposer aus app.js)
Der Chat der neuen Ansicht übernahm nur das Ausblenden der Leiste, nicht das Nachschieben der Eingabe: auf kleinen Handys (iPhone SE, Android mit
hoher Tastatur) konnte die Eingabezeile unter der Tastatur stehen bleiben, und die letzte Nachricht verschwand beim Schrumpfen des Verlaufs.
Jetzt wie in app.js: 350 ms nach dem Fokus im Composer und bei jedem visualViewport-Resize wird die Eingabe in den sichtbaren Bereich geschoben
(composerVersatz, nur wenn verdeckt, 8 px Luft, nur am Handy), und ein ResizeObserver hält die neueste Nachricht sichtbar, wenn man unten war.
Hörer und Beobachter werden beim Neuaufbau abgebaut. Tests für die Rechnung und die Verdrahtung in knAnsichtChat.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:44:20 +00:00

193 lines
7.9 KiB
JavaScript

/* Kundenansicht neu (P2): Bereich Chat. Verlauf und Eingabezeile des gemeinsamen Composers (window.ChatComposer, chat-composer.js),
Poll alle 10 s mit ?since=, Gelesen-Markierung, Vorgabe aus "Postfach anfragen". Nachrichten gehen nur als Text in den Verlauf. */
import { api } from './api.js'
import { h, leeren } from './dom.js'
import { K, fmtDatumZeit, meldeZaehlerNeu, nimmChatVorgabe } from './ansicht-hilfen.js'
import { HANDY_ABFRAGE } from './leiste.js'
export const CHAT_TEXTE = {
platzhalter: 'Nachricht an das WEBklar-Team…',
kurz: 'Nachricht…',
gesperrt: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.',
leer: 'Noch keine Nachrichten.',
}
const POLL_MS = 10000
const dateiUrl = (att) => `/api/chat/attachments/${encodeURIComponent(att.id)}`
/** 'Du · 14.10.26, 10:05' oder '<Name oder Webklar-Team> · …'. */
export function nachrichtMeta(msg) {
const meine = msg?.sender === 'customer'
return `${meine ? 'Du' : msg?.senderName || 'Webklar-Team'} · ${fmtDatumZeit(msg?.createdAt)}`
}
/**
* Um wie viele Pixel muss die Seite scrollen, damit die Eingabe im sichtbaren Bereich (visualViewport) steht? Aus revealChatComposer (app.js:2240-2260):
* nur nachschieben, wenn sie wirklich verdeckt ist (8 px Luft), nie die Seite nach oben pinnen. Unter 1 Pixel und ungültige Werte ergeben 0.
*/
export function composerVersatz({ rect, visibleTop, visibleBottom }, luft = 8) {
let delta = 0
if (rect.bottom > visibleBottom - luft) delta = rect.bottom - visibleBottom + luft
else if (rect.top < visibleTop + luft) delta = rect.top - visibleTop - luft
return Number.isFinite(delta) && Math.abs(delta) > 1 ? delta : 0
}
/**
* Die Eingabe über der Tastatur halten (E-Fix M5, wie app.js): 350 ms nach dem Fokus im Composer (die Tastatur-Animation auf iOS) und bei jedem
* visualViewport-Resize die Eingabe in den sichtbaren Bereich schieben. Nur am Handy und nur, solange der Fokus im Composer liegt.
* Rückgabe { abbau } entfernt die Hörer.
*/
export function verdrahteEingabe({ win = globalThis.window, doc = globalThis.document, wurzel }) {
const vv = win?.visualViewport
const zeige = () => {
if (!vv || !wurzel.contains(doc.activeElement) || !win.matchMedia?.(HANDY_ABFRAGE).matches) return
const delta = composerVersatz({ rect: wurzel.getBoundingClientRect(), visibleTop: vv.offsetTop, visibleBottom: vv.offsetTop + vv.height })
if (delta) win.scrollBy(0, delta)
}
const beiFokus = (ev) => { if (wurzel.contains(ev.target) && ev.target.matches?.('textarea, input')) win.setTimeout(zeige, 350) }
const frame = (fn) => (win.requestAnimationFrame ? win.requestAnimationFrame(fn) : fn())
const beiVv = () => frame(() => frame(zeige))
doc.addEventListener('focusin', beiFokus)
vv?.addEventListener('resize', beiVv)
return {
abbau() {
doc.removeEventListener('focusin', beiFokus)
vv?.removeEventListener('resize', beiVv)
},
}
}
let aktiv = null // der laufende Chat: { timer, composer, eingabe, beobachter }
function raeumeAuf() {
if (!aktiv) return
if (aktiv.timer) clearInterval(aktiv.timer)
try { aktiv.composer?.destroy?.() } catch { /* schon abgebaut */ }
try { aktiv.eingabe?.abbau() } catch { /* schon abgebaut */ }
try { aktiv.beobachter?.disconnect() } catch { /* schon abgebaut */ }
aktiv = null
}
export async function zeigeChat(wurzel, { route, me, nav } = {}) {
raeumeAuf()
const eigener = { timer: null, composer: null, eingabe: null, beobachter: null }
aktiv = eigener
const gilt = () => aktiv === eigener
leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …'))
const Composer = globalThis.ChatComposer
if (!Composer) {
leeren(wurzel).append(h('p', { class: K.fehler }, 'Der Chat konnte nicht gestartet werden. Bitte lade die Seite neu.'))
return
}
const viewAs = me?.viewAs === true
const verlauf = h('div', { class: 'chat-messages kn-chat-verlauf', role: 'log', 'aria-label': 'Nachrichtenverlauf' })
const composerWurzel = h('div', { class: 'kn-chat-composer' })
const kasten = h('div', { class: 'chat-box kn-chat-kasten' }, verlauf, composerWurzel)
leeren(wurzel).append(h('div', { class: 'kn-chat' }, kasten))
const gezeigt = new Set()
let zuletzt = ''
let leerHinweis = null
let fehler = null
const hinweisLeer = () => {
if (gezeigt.size || leerHinweis) return
leerHinweis = h('p', { class: K.leer }, CHAT_TEXTE.leer)
verlauf.append(leerHinweis)
}
const raeumeHinweise = () => { leerHinweis?.remove(); leerHinweis = null; fehler?.remove(); fehler = null }
/** Neue Nachrichten anhängen (nach id entdoppelt); Rückgabe: die wirklich neuen. */
function haengeAn(nachrichten) {
const neu = []
for (const msg of nachrichten) {
if (!msg || gezeigt.has(msg.id)) continue
gezeigt.add(msg.id)
neu.push(msg)
}
if (!neu.length) return neu
raeumeHinweise()
for (const msg of neu) {
verlauf.append(Composer.renderMessage(msg, { mine: msg.sender === 'customer', meta: nachrichtMeta(msg), fileUrl: dateiUrl }))
if (msg.createdAt > zuletzt) zuletzt = msg.createdAt
}
verlauf.scrollTop = verlauf.scrollHeight
return neu
}
// Nachrichten als gelesen markieren; im Ansichtsmodus nie (der Kunde hat sie nicht gelesen)
async function markiereGelesen() {
if (viewAs) return
await api('/api/chat/read', { method: 'POST' }).catch(() => {})
if (gilt()) meldeZaehlerNeu('chat')
}
async function hole() {
const frage = zuletzt ? `?since=${encodeURIComponent(zuletzt)}` : ''
const daten = await api(`/api/chat/messages${frage}`)
if (!gilt()) return []
return haengeAn(Array.isArray(daten?.messages) ? daten.messages : [])
}
async function poll() {
if (!gilt()) return
if (!wurzel.isConnected) { clearInterval(eigener.timer); return }
if (globalThis.document?.hidden) return
if (wurzel.closest?.('[hidden]')) return // der Chat ist gerade nicht zu sehen
try {
const neu = await hole()
if (neu.some((m) => m.sender !== 'customer')) await markiereGelesen()
} catch { /* der nächste Poll versucht es erneut */ }
}
Composer.trackScroll(verlauf)
eigener.composer = Composer.create({
root: composerWurzel,
dropZone: kasten,
uploadUrl: '/api/chat/attachments',
placeholder: CHAT_TEXTE.platzhalter,
placeholderShort: CHAT_TEXTE.kurz,
disabled: viewAs,
disabledText: CHAT_TEXTE.gesperrt,
send: async ({ text, attachmentIds }) => {
const { message } = await api('/api/chat/messages', { method: 'POST', body: { text, attachmentIds } })
return message
},
onSent: (message) => { haengeAn([message]) },
})
// Eingabe über der Tastatur halten; wird der Verlauf kleiner (Tastatur, Leiste), bleibt die neueste Nachricht sichtbar, sofern man unten war (E-Fix M5)
eigener.eingabe = verdrahteEingabe({ win: globalThis.window, doc: globalThis.document, wurzel: composerWurzel })
if (typeof globalThis.ResizeObserver === 'function') {
eigener.beobachter = new globalThis.ResizeObserver(() => { if (verlauf.dataset.stick !== 'false') verlauf.scrollTop = verlauf.scrollHeight })
eigener.beobachter.observe(verlauf)
}
const vorgabe = nimmChatVorgabe()
if (vorgabe) {
const feld = composerWurzel.querySelector('textarea')
if (feld) {
feld.value = vorgabe
if (typeof Event === 'function') feld.dispatchEvent(new Event('input', { bubbles: true }))
feld.focus()
if (typeof feld.setSelectionRange === 'function') feld.setSelectionRange(vorgabe.length, vorgabe.length)
}
}
try {
await hole()
if (!gilt()) return
hinweisLeer()
} catch (f) {
if (!gilt()) return
fehler = h('p', { class: K.fehler }, `Nachrichten konnten nicht geladen werden: ${f.message}`)
verlauf.append(fehler)
}
await markiereGelesen()
if (!gilt()) return
eigener.timer = setInterval(poll, POLL_MS)
eigener.timer?.unref?.() // im Browser eine Zahl; in Node hält der Timer den Prozess nicht fest
}