Pilot-Feedback 17.10.: Der Chat wirkte durchsichtig und verrutscht. - Handy: Inhalt steht fest zwischen Kopf und Leiste (wie das Blatt); die Eingabe sitzt höchstens 6 px über der Leiste bzw. dem Widerrufsstreifen und bei offener Tastatur am unteren Rand des sichtbaren Bereichs. "Chat" steht klein im Kopf (die H1 bleibt nur für Screenreader), der Kopf ist deckend mit Haarlinie, der Verlauf beginnt direkt darunter und scrollt in sich. - Desktop: Spalte in Fensterhöhe, Chat höchstens 760 px breit und mittig, Verlauf füllt die Höhe, Eingabe am unteren Spaltenrand. - Verlauf steht unten (neueste Nachricht sichtbar), kurze Verläufe unten bei der Eingabe; Blasen fremd #f8f9fb, eigen #ebeef1. - "Senden" bleibt schwarz, auch bei leerem Feld (gesperrt im Ansichtsmodus weiter gedämpft). - haltFokus: Druck auf Senden, Anhang oder Mikrofon nimmt dem Textfeld den Fokus nicht; sonst kommt die Leiste zurück, schiebt die feste Eingabe hoch und der Tipp auf "Senden" geht bei offener Tastatur verloren (im Browser nachgestellt). public/chat-composer.js und die alte Ansicht bleiben unverändert. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
210 lines
8.8 KiB
JavaScript
210 lines
8.8 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)
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|