/* 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 ' · …'. */ 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) }, } } /** * Druck auf einen Knopf der Eingabezeile (Senden, Anhang, Mikrofon) nimmt dem Textfeld den Fokus nicht. Sonst endet mit dem Fokus die Tastatur-Stellung der Hülle * (leiste.js): die Leiste kommt zurück und schiebt die fest stehende Eingabe um ihre Höhe nach oben, bevor der Finger loslässt, und der Tipp auf „Senden“ * geht ins Leere (Android). Das Textfeld behält den Fokus, die Tastatur bleibt wie in jedem Chat offen. Tab und Tastatur-Bedienung bleiben unberührt. * Rückgabe { abbau } entfernt den Hörer. */ export function haltFokus(wurzel) { const beiDruck = (ev) => { const knopf = ev.target?.closest?.('button') if (knopf && wurzel.contains(knopf)) ev.preventDefault() } wurzel.addEventListener('mousedown', beiDruck) return { abbau() { wurzel.removeEventListener('mousedown', beiDruck) } } } let aktiv = null // der laufende Chat: { timer, composer, eingabe, beobachter, fokus } 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.fokus?.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, fokus: 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 }) eigener.fokus = haltFokus(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 }