/* Chat im Kundenbereich und im Ticketsystem (laedt diese Datei zur Laufzeit): Anhaenge (Bild, Kamera, Datei) und Sprachnachrichten. ChatComposer.create({...}) baut die Eingabezeile, ChatComposer.renderMessage(...) eine Sprechblase. Erlaubte Endungen und Grenzen prueft der Server noch einmal (server/services/chatAttachments.js). */ (function () { 'use strict' const MAX_FILE_BYTES = 25 * 1024 * 1024 const MAX_FILES = 10 const MAX_TEXT = 2000 const MAX_PARALLEL_UPLOADS = 3 // Server puffert jede Datei im Speicher const MAX_VOICE_MS = 5 * 60 * 1000 const MIN_VOICE_MS = 800 const WAVE_BARS = 28 const ALLOWED_EXT = new Set(( 'jpg jpeg png gif webp heic heif avif bmp tif tiff svg pdf txt csv rtf md doc docx xls xlsx ppt pptx odt ods odp ' + 'pages numbers key ai eps psd indd sketch fig xd zip rar 7z mp3 m4a aac wav ogg oga opus webm mp4 mov m4v ' + 'ttf otf woff woff2 vcf ics json xml' ).split(' ')) // Bilder, die jeder Browser anzeigen kann (alles andere erscheint als Datei) const PREVIEW_IMAGE = /^image\/(jpeg|png|gif|webp)$/ const ICONS = { clip: '', image: '', camera: '', file: '', mic: '', stop: '', trash: '', send: '', play: '', pause: '', close: '', download: '', switch: '', retry: '', check: '', } function icon(name) { const span = document.createElement('span') span.className = 'chat-ico' span.setAttribute('aria-hidden', 'true') span.innerHTML = `${ICONS[name]}` return span } /** Kleiner DOM-Baukasten: Texte immer ueber textContent, nie als HTML. */ function el(tag, props, ...children) { const node = document.createElement(tag) for (const [key, value] of Object.entries(props || {})) { if (value === null || value === undefined || value === false) continue if (key === 'class') node.className = value else if (key === 'text') node.textContent = value else if (key.startsWith('on')) node.addEventListener(key.slice(2), value) else node.setAttribute(key, value === true ? '' : String(value)) } for (const child of children.flat()) { if (child !== null && child !== undefined && child !== false) node.append(child) } return node } function fmtBytes(bytes) { const n = Number(bytes) || 0 if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1).replace('.', ',')} MB` return `${Math.max(1, Math.round(n / 1024))} KB` } function fmtDuration(ms, round = Math.round) { const s = Math.max(0, round((Number(ms) || 0) / 1000)) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` } const extOf = (name) => ((/\.([A-Za-z0-9]{1,8})$/.exec(String(name || '')) || [])[1] || '').toLowerCase() const withQuery = (url, query) => `${url}${url.includes('?') ? '&' : '?'}${query}` const isTouch = () => Boolean(window.matchMedia && window.matchMedia('(pointer: coarse)').matches) // Handy/Tablet mit eigener Kamera-App (iPadOS meldet sich als Mac mit Touch) const isMobileOs = () => /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) function stamp() { const d = new Date() const p = (n) => String(n).padStart(2, '0') return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}` } function stopStream(stream) { if (stream) stream.getTracks().forEach((t) => t.stop()) } function mediaErrorText(err, device) { const camera = device === 'camera' const name = (err && err.name) || '' if (name === 'NotAllowedError' || name === 'SecurityError') { return camera ? 'Kein Zugriff auf die Kamera. Bitte den Kamerazugriff in den Browser-Einstellungen erlauben oder ein vorhandenes Bild auswählen.' : 'Kein Zugriff auf das Mikrofon. Bitte den Mikrofonzugriff in den Browser-Einstellungen erlauben.' } if (name === 'NotFoundError' || name === 'OverconstrainedError' || name === 'DevicesNotFoundError') { return camera ? 'Keine Kamera gefunden.' : 'Kein Mikrofon gefunden.' } if (name === 'NotReadableError' || name === 'AbortError') { return camera ? 'Die Kamera wird gerade von einem anderen Programm benutzt.' : 'Das Mikrofon wird gerade von einem anderen Programm benutzt.' } return camera ? 'Die Kamera konnte nicht gestartet werden.' : 'Das Mikrofon konnte nicht gestartet werden.' } // ------------------------------------------------------------------------- // Vollbild-Dialoge (Bildansicht, Kamera) // ------------------------------------------------------------------------- function trapFocus(event, root) { const items = [...root.querySelectorAll('button, a[href]')].filter((x) => !x.disabled && x.offsetParent !== null) if (!items.length) return const first = items[0] const last = items[items.length - 1] if (event.shiftKey && document.activeElement === first) { event.preventDefault() last.focus() } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault() first.focus() } } /** Dialog anzeigen; Escape schliesst, Fokus bleibt im Dialog und kehrt danach zurueck. */ function openOverlay(node, { label, onClose }) { const previous = document.activeElement node.classList.add('chat-overlay') node.setAttribute('role', 'dialog') node.setAttribute('aria-modal', 'true') node.setAttribute('aria-label', label) const onKey = (event) => { if (event.key === 'Escape') { event.preventDefault() close() } else if (event.key === 'Tab') { trapFocus(event, node) } } let closed = false function close() { if (closed) return closed = true document.removeEventListener('keydown', onKey, true) node.remove() if (!document.querySelector('.chat-overlay')) document.documentElement.classList.remove('chat-overlay-open') if (onClose) onClose() if (previous && previous.focus) previous.focus() } document.addEventListener('keydown', onKey, true) document.documentElement.classList.add('chat-overlay-open') document.body.append(node) return close } function openLightbox(src, name, downloadUrl) { const img = el('img', { src, alt: name }) const closeBtn = el('button', { type: 'button', class: 'chat-ov-btn', 'aria-label': 'Schließen', title: 'Schließen' }, icon('close')) const stage = el('div', { class: 'chat-lightbox-stage' }, img) const box = el('div', { class: 'chat-lightbox' }, el('div', { class: 'chat-ov-top' }, el('span', { class: 'chat-ov-title', text: name }), el('a', { class: 'chat-ov-btn', href: downloadUrl, download: name, 'aria-label': 'Herunterladen', title: 'Herunterladen' }, icon('download')), closeBtn), stage) const close = openOverlay(box, { label: `Bild ${name}` }) closeBtn.addEventListener('click', close) stage.addEventListener('click', (event) => { if (event.target !== img) close() }) closeBtn.focus() } /** Kamera im Browser: Vorschau, Ausloeser, Kamera wechseln, Foto pruefen und verwenden. */ function openCamera({ onPhoto, onPickInstead }) { let stream = null let devices = null let deviceIndex = 0 let photo = null let stillUrl = '' const video = el('video', { class: 'chat-cam-video', autoplay: true, playsinline: true, muted: true }) video.muted = true const still = el('img', { class: 'chat-cam-still', alt: 'Aufgenommenes Foto' }) const msg = el('p', { class: 'chat-cam-msg', role: 'alert' }) const pickBtn = el('button', { type: 'button', class: 'chat-ov-btn wide' }, icon('image'), el('span', { text: 'Bild auswählen' })) const msgBox = el('div', { class: 'chat-cam-msgbox', hidden: true }, msg, pickBtn) const closeBtn = el('button', { type: 'button', class: 'chat-ov-btn', 'aria-label': 'Kamera schließen', title: 'Schließen' }, icon('close')) const shutter = el('button', { type: 'button', class: 'chat-shutter', 'aria-label': 'Foto aufnehmen', title: 'Foto aufnehmen', disabled: true }) const switchBtn = el('button', { type: 'button', class: 'chat-ov-btn', 'aria-label': 'Kamera wechseln', title: 'Kamera wechseln', hidden: true }, icon('switch')) const retakeBtn = el('button', { type: 'button', class: 'chat-ov-btn wide' }, icon('retry'), el('span', { text: 'Nochmal' })) const useBtn = el('button', { type: 'button', class: 'chat-ov-btn wide primary' }, icon('check'), el('span', { text: 'Foto verwenden' })) const box = el('div', { class: 'chat-camera' }, el('div', { class: 'chat-ov-top' }, el('span', { class: 'chat-ov-title', text: 'Foto aufnehmen' }), closeBtn), el('div', { class: 'chat-cam-stage' }, video, still, msgBox), el('div', { class: 'chat-cam-controls chat-cam-live' }, el('span', { class: 'chat-cam-side' }), shutter, el('span', { class: 'chat-cam-side' }, switchBtn)), el('div', { class: 'chat-cam-controls chat-cam-review' }, retakeBtn, useBtn)) const close = openOverlay(box, { label: 'Kamera', onClose() { stopStream(stream) stream = null if (stillUrl) URL.revokeObjectURL(stillUrl) }, }) function fail(text) { msg.textContent = text msgBox.hidden = false video.hidden = true shutter.disabled = true pickBtn.focus() } async function start() { stopStream(stream) stream = null shutter.disabled = true const device = devices && devices[deviceIndex] const constraints = device ? { deviceId: { exact: device.deviceId } } : { facingMode: { ideal: 'environment' } } let next try { next = await navigator.mediaDevices.getUserMedia({ audio: false, video: { ...constraints, width: { ideal: 1920 }, height: { ideal: 1080 } }, }) } catch (err) { fail(mediaErrorText(err, 'camera')) return } if (!box.isConnected) { stopStream(next) return } stream = next const track = stream.getVideoTracks()[0] const settings = (track && track.getSettings && track.getSettings()) || {} // Frontkamera spiegeln wie ein Spiegel; das Foto selbst bleibt seitenrichtig video.classList.toggle('is-mirrored', settings.facingMode !== 'environment') video.srcObject = stream await video.play().catch(() => {}) shutter.disabled = false if (!box.classList.contains('is-review')) shutter.focus() if (!devices) { const all = await navigator.mediaDevices.enumerateDevices().catch(() => []) devices = all.filter((d) => d.kind === 'videoinput' && d.deviceId) deviceIndex = Math.max(0, devices.findIndex((d) => d.deviceId === settings.deviceId)) switchBtn.hidden = devices.length < 2 } } shutter.addEventListener('click', () => { const width = video.videoWidth const height = video.videoHeight if (!width || !height) return const canvas = document.createElement('canvas') canvas.width = width canvas.height = height canvas.getContext('2d').drawImage(video, 0, 0, width, height) canvas.toBlob((blob) => { if (!blob) return photo = new File([blob], `Foto_${stamp()}.jpg`, { type: 'image/jpeg' }) if (stillUrl) URL.revokeObjectURL(stillUrl) stillUrl = URL.createObjectURL(blob) still.src = stillUrl box.classList.add('is-review') useBtn.focus() }, 'image/jpeg', 0.9) }) retakeBtn.addEventListener('click', () => { photo = null box.classList.remove('is-review') shutter.focus() }) useBtn.addEventListener('click', () => { if (!photo) return const file = photo close() onPhoto(file) }) switchBtn.addEventListener('click', () => { if (!devices || devices.length < 2) return deviceIndex = (deviceIndex + 1) % devices.length start() }) pickBtn.addEventListener('click', () => { close() onPickInstead() }) closeBtn.addEventListener('click', close) closeBtn.focus() start() } // ------------------------------------------------------------------------- // Anzeige in der Sprechblase // ------------------------------------------------------------------------- let playingAudio = null // immer nur eine Aufnahme gleichzeitig /** Kompakter Player (Sprachnachricht/Audiodatei); die Dauer kommt aus den Metadaten der Aufnahme. */ function audioPlayer(src, { durationMs = 0, label = '', downloadUrl = '', downloadName = '' } = {}) { const playBtn = el('button', { type: 'button', class: 'chat-audio-play', 'aria-label': 'Abspielen', title: 'Abspielen' }, icon('play')) const fill = el('span', { class: 'chat-audio-fill' }) const bar = el('span', { class: 'chat-audio-bar', role: 'slider', tabindex: 0, 'aria-label': 'Wiedergabeposition', 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuenow': 0, }, fill) const time = el('span', { class: 'chat-audio-time', text: durationMs ? fmtDuration(durationMs) : '' }) const box = el('div', { class: 'chat-audio' }, playBtn, el('span', { class: 'chat-audio-main' }, label ? el('span', { class: 'chat-audio-label', text: label }) : null, bar), time, downloadUrl ? el('a', { class: 'chat-audio-dl', href: downloadUrl, download: downloadName || true, 'aria-label': 'Herunterladen', title: 'Herunterladen' }, icon('download')) : null) let audio = null const total = () => (audio && Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : durationMs / 1000) function paint() { const d = total() const ratio = d ? Math.min(1, audio.currentTime / d) : 0 fill.style.width = `${ratio * 100}%` bar.setAttribute('aria-valuenow', String(Math.round(ratio * 100))) // Vor dem Abspielen die Gesamtdauer, waehrenddessen die abgelaufene Zeit const atStart = audio.paused && !audio.currentTime const shown = atStart ? d : audio.currentTime time.textContent = d || shown ? fmtDuration(shown * 1000, atStart ? Math.round : Math.floor) : '' } function setPlaying(on) { playBtn.replaceChildren(icon(on ? 'pause' : 'play')) playBtn.setAttribute('aria-label', on ? 'Pause' : 'Abspielen') playBtn.title = on ? 'Pause' : 'Abspielen' box.classList.toggle('is-playing', on) } function ensure() { if (audio) return audio audio = new Audio() audio.preload = 'metadata' audio.addEventListener('timeupdate', paint) audio.addEventListener('loadedmetadata', paint) audio.addEventListener('play', () => setPlaying(true)) audio.addEventListener('pause', () => setPlaying(false)) audio.addEventListener('ended', () => { setPlaying(false) audio.currentTime = 0 paint() }) audio.addEventListener('error', () => { box.classList.add('is-error') setPlaying(false) time.textContent = 'nicht abspielbar' }) audio.src = src return audio } function seekTo(seconds) { const a = ensure() const d = total() if (!d) return a.currentTime = Math.max(0, Math.min(d, seconds)) paint() } playBtn.addEventListener('click', () => { const a = ensure() if (!a.paused) { a.pause() return } if (playingAudio && playingAudio !== a) playingAudio.pause() playingAudio = a a.play().catch(() => {}) }) bar.addEventListener('click', (event) => { const rect = bar.getBoundingClientRect() seekTo(((event.clientX - rect.left) / rect.width) * total()) }) bar.addEventListener('keydown', (event) => { if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return event.preventDefault() seekTo(ensure().currentTime + (event.key === 'ArrowRight' ? 5 : -5)) }) box.stop = () => { if (audio) audio.pause() } return box } function keepAtBottom(node) { const box = node.closest('.chat-messages') if (box && box.dataset.stick !== 'false') box.scrollTop = box.scrollHeight } /** Merkt, ob der Nutzer unten im Verlauf ist (dann bleibt er unten, wenn Bilder nachladen). */ function trackScroll(box) { box.addEventListener('scroll', () => { box.dataset.stick = String(box.scrollHeight - box.scrollTop - box.clientHeight < 80) }, { passive: true }) } function imageTile(att, fileUrl, single) { const src = fileUrl(att) const img = el('img', { src, alt: att.name, loading: 'lazy', decoding: 'async' }) if (single && att.width && att.height) { // Platz schon vor dem Laden reservieren (kein Springen im Verlauf) const scale = Math.min(1, 280 / att.width, 320 / att.height) img.style.width = `${Math.max(64, Math.round(att.width * scale))}px` img.style.aspectRatio = `${att.width} / ${att.height}` } const tile = el('button', { type: 'button', class: 'chat-att-image', title: att.name, 'aria-label': `Bild ${att.name} vergrößern` }, img) img.addEventListener('load', () => keepAtBottom(tile)) img.addEventListener('error', () => { tile.disabled = true tile.classList.add('is-broken') tile.replaceChildren(icon('image'), el('span', { text: 'Bild nicht verfügbar' })) }) tile.addEventListener('click', () => openLightbox(src, att.name, withQuery(src, 'download=1'))) return tile } function fileChip(att, fileUrl) { const pdf = att.type === 'application/pdf' const url = fileUrl(att) const ext = extOf(att.name).toUpperCase().slice(0, 4) return el('a', { class: 'chat-att-file', href: pdf ? url : withQuery(url, 'download=1'), target: pdf ? '_blank' : null, rel: 'noopener', download: pdf ? null : att.name, title: pdf ? `${att.name} öffnen` : `${att.name} herunterladen`, }, el('span', { class: 'chat-file-icon' }, icon('file'), ext ? el('span', { class: 'chat-file-ext', text: ext }) : null), el('span', { class: 'chat-file-text' }, el('span', { class: 'chat-file-name', text: att.name }), el('span', { class: 'chat-file-size', text: fmtBytes(att.size) })), icon('download')) } function renderAttachments(atts, fileUrl) { const wrap = el('div', { class: 'chat-atts' }) const images = atts.filter((a) => a.kind === 'image') if (images.length) { const grid = el('div', { class: `chat-att-images${images.length === 1 ? ' single' : ''}` }) images.forEach((a) => grid.append(imageTile(a, fileUrl, images.length === 1))) wrap.append(grid) } for (const a of atts) { if (a.kind === 'voice' || a.kind === 'audio') { wrap.append(audioPlayer(fileUrl(a), { durationMs: a.durationMs, label: a.kind === 'voice' ? 'Sprachnachricht' : a.name, downloadUrl: withQuery(fileUrl(a), 'download=1'), downloadName: a.name, })) } else if (a.kind !== 'image') { wrap.append(fileChip(a, fileUrl)) } } return wrap } /** * Sprechblase: Anhaenge, Text, Absender/Zeit. * fileUrl(att) liefert die Adresse des Anhangs (Kunde: /api/chat/..., Ticketsystem: signierter Link unter /api/team/...). */ function renderMessage(msg, { mine, meta, fileUrl }) { const atts = Array.isArray(msg.attachments) ? msg.attachments : [] const bubble = el('div', { class: `chat-bubble ${mine ? 'mine' : 'theirs'}${atts.length ? ' has-atts' : ''}` }) if (atts.length) bubble.append(renderAttachments(atts, fileUrl)) if (msg.text) bubble.append(el('div', { class: 'chat-text', text: msg.text })) bubble.append(el('span', { class: 'chat-meta', text: meta })) return bubble } // ------------------------------------------------------------------------- // Eingabezeile // ------------------------------------------------------------------------- function startMeter(stream, onLevel) { try { const Ctx = window.AudioContext || window.webkitAudioContext if (!Ctx) return null const ctx = new Ctx() const source = ctx.createMediaStreamSource(stream) const analyser = ctx.createAnalyser() analyser.fftSize = 1024 source.connect(analyser) const data = new Uint8Array(analyser.fftSize) let raf = 0 let last = 0 const loop = (now) => { raf = requestAnimationFrame(loop) if (now - last < 90) return last = now analyser.getByteTimeDomainData(data) let sum = 0 for (let i = 0; i < data.length; i += 1) { const x = (data[i] - 128) / 128 sum += x * x } // Wurzel hebt leise Stimmen sichtbar an, laute stossen nicht sofort an onLevel(Math.min(1, Math.sqrt(Math.sqrt(sum / data.length)) * 1.5)) } raf = requestAnimationFrame(loop) if (ctx.state === 'suspended') ctx.resume().catch(() => {}) return { stop() { cancelAnimationFrame(raf) try { source.disconnect() } catch { /* schon getrennt */ } ctx.close().catch(() => {}) }, } } catch { return null } } function pickAudioType() { if (!window.MediaRecorder || typeof MediaRecorder.isTypeSupported !== 'function') return '' return ['audio/webm;codecs=opus', 'audio/mp4;codecs=mp4a.40.2', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/webm'] .find((t) => MediaRecorder.isTypeSupported(t)) || '' } /** * Eingabezeile mit Anhaengen und Sprachaufnahme. * options: root, dropZone, uploadUrl (POST roh, DELETE uploadUrl/id), send({ text, attachmentIds }) -> message, * onSent(message), placeholder, placeholderShort (schmale Bildschirme), disabled, disabledText, * requestHeaders() -> Objekt oder Promise (zusaetzliche Header fuer Hochladen und Entfernen, z. B. das * Bearer-JWT des Ticketsystems; ohne die Option wie bisher nur das Cookie) */ function create(options) { const opts = { placeholder: 'Nachricht schreiben…', disabled: false, disabledText: '', onSent() {}, ...options } const narrow = Boolean(window.matchMedia && window.matchMedia('(max-width: 640px)').matches) const placeholder = narrow && opts.placeholderShort ? opts.placeholderShort : opts.placeholder const root = opts.root const zone = opts.dropZone || root const canRecord = Boolean(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.MediaRecorder) const items = [] let recording = null let sending = false let sendQueued = false let destroyed = false let seq = 0 const tray = el('div', { class: 'chat-tray', role: 'list', 'aria-label': 'Anhänge', hidden: true }) const note = el('p', { class: 'chat-composer-note', role: 'alert', hidden: true }) const textarea = el('textarea', { maxlength: MAX_TEXT, rows: 1, 'aria-label': 'Nachricht', placeholder: opts.disabled ? opts.disabledText || placeholder : placeholder, }) const attachBtn = el('button', { type: 'button', class: 'chat-icon-btn chat-attach-btn', 'aria-label': 'Anhang hinzufügen', title: 'Bild, Foto oder Datei anhängen', 'aria-haspopup': 'menu', 'aria-expanded': 'false', }, icon('clip')) const imageInput = el('input', { type: 'file', accept: 'image/*', multiple: true, hidden: true, tabindex: -1 }) const fileInput = el('input', { type: 'file', multiple: true, hidden: true, tabindex: -1 }) const captureInput = el('input', { type: 'file', accept: 'image/*', capture: 'environment', hidden: true, tabindex: -1 }) const menuItem = (name, label, handler) => el('button', { type: 'button', role: 'menuitem', onclick: () => { closeMenu() handler() }, }, icon(name), el('span', { text: label })) const menu = el('div', { class: 'chat-menu', role: 'menu', 'aria-label': 'Anhang', hidden: true }, menuItem('image', 'Bild', () => imageInput.click()), menuItem('camera', 'Kamera', takePhoto), menuItem('file', 'Datei', () => fileInput.click())) const attachWrap = el('div', { class: 'chat-attach' }, attachBtn, menu) const micBtn = el('button', { type: 'button', class: 'chat-icon-btn chat-mic', 'aria-label': 'Sprachnachricht aufnehmen', title: 'Sprachnachricht aufnehmen', hidden: !canRecord, }, icon('mic')) const sendLabel = el('span', { class: 'chat-send-label', text: 'Senden' }) const sendBtn = el('button', { type: 'button', class: 'chat-send', 'aria-label': 'Senden' }, icon('send'), sendLabel) const recTime = el('span', { class: 'chat-rec-time', text: '0:00' }) const recWave = el('span', { class: 'chat-rec-wave', 'aria-hidden': 'true' }) for (let i = 0; i < WAVE_BARS; i += 1) recWave.append(el('i')) const recDiscard = el('button', { type: 'button', class: 'chat-icon-btn chat-rec-discard', 'aria-label': 'Aufnahme verwerfen', title: 'Verwerfen' }, icon('trash')) const recStop = el('button', { type: 'button', class: 'chat-icon-btn chat-rec-stop', 'aria-label': 'Aufnahme beenden und anhören', title: 'Aufnahme beenden (vor dem Senden anhören)', }, icon('stop')) const recBar = el('div', { class: 'chat-rec' }, recDiscard, el('span', { class: 'chat-rec-dot', 'aria-hidden': 'true' }), el('span', { class: 'chat-rec-label', text: 'Aufnahme' }), recTime, recWave, recStop) const composer = el('div', { class: 'chat-composer' }, tray, el('div', { class: 'chat-input-row' }, attachWrap, textarea, recBar, micBtn, sendBtn), note, imageInput, fileInput, captureInput) root.replaceChildren(composer) if (opts.disabled) { composer.classList.add('is-disabled') for (const control of [textarea, attachBtn, micBtn, sendBtn]) control.disabled = true return { destroy: () => root.replaceChildren(), focus() {} } } // ------------------------------------------------------------ Hinweise -- function showNote(text, { info = false } = {}) { note.textContent = text note.classList.toggle('is-info', info) note.hidden = false } function hideNote() { note.hidden = true note.textContent = '' } // ---------------------------------------------------------------- Menue -- function openMenu() { menu.hidden = false attachBtn.setAttribute('aria-expanded', 'true') menu.querySelector('button').focus() } function closeMenu() { menu.hidden = true attachBtn.setAttribute('aria-expanded', 'false') } attachBtn.addEventListener('click', () => (menu.hidden ? openMenu() : closeMenu())) menu.addEventListener('keydown', (event) => { const buttons = [...menu.querySelectorAll('button')] const i = buttons.indexOf(document.activeElement) if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault() const next = (i + (event.key === 'ArrowDown' ? 1 : buttons.length - 1)) % buttons.length buttons[next].focus() } }) const onDocClick = (event) => { if (!menu.hidden && !attachWrap.contains(event.target)) closeMenu() } const onDocKey = (event) => { if (event.key === 'Escape' && !menu.hidden) { closeMenu() attachBtn.focus() } } document.addEventListener('click', onDocClick) document.addEventListener('keydown', onDocKey) // ------------------------------------------------------------ Anhaenge -- const uploading = () => items.some((i) => i.status === 'queued' || i.status === 'uploading') function addFiles(list, hint = '') { const files = [...(list || [])] if (!files.length) return hideNote() const problems = [] for (const file of files) { if (items.length >= MAX_FILES) { problems.push(`Höchstens ${MAX_FILES} Anhänge pro Nachricht.`) break } let name = file.name || '' if (hint === 'camera' && (!name || /^image\.\w+$/i.test(name))) name = `Foto_${stamp()}.${extOf(name) || 'jpg'}` else if (!name || name === 'image.png') name = `Bild_${stamp()}.${extOf(name) || 'png'}` // eingefuegter Screenshot const image = PREVIEW_IMAGE.test(file.type) if (!file.size) problems.push(`„${name}“ ist leer.`) else if (file.size > MAX_FILE_BYTES) problems.push(`„${name}“ ist größer als 25 MB.`) else if (!image && !ALLOWED_EXT.has(extOf(name))) problems.push(`„${name}“: Dieser Dateityp wird nicht unterstützt.`) else addItem({ file, name, kind: image ? 'image' : 'file', hint }) } if (problems.length) showNote(problems.join(' ')) } function addItem({ file, name, kind, hint, durationMs = 0 }) { const item = { key: ++seq, file, name, kind, hint, durationMs, size: file.size, status: 'queued', progress: 0, error: '', xhr: null, meta: null, url: '', width: 0, height: 0, player: null, bar: null, } items.push(item) if (kind === 'image' || kind === 'voice') item.url = URL.createObjectURL(file) if (kind === 'image') { const probe = new Image() probe.onload = () => { item.width = probe.naturalWidth item.height = probe.naturalHeight upload(item) } probe.onerror = () => { // Kann der Browser das Bild nicht anzeigen, geht es als Datei raus URL.revokeObjectURL(item.url) item.url = '' item.kind = 'file' upload(item) } probe.src = item.url } else { upload(item) } renderTray() return item } function extraHeaders() { return Promise.resolve(opts.requestHeaders ? opts.requestHeaders() : {}).then((headers) => headers || {}) } function deleteOnServer(item) { if (!item.meta || !item.meta.id) return extraHeaders() .then((headers) => fetch(`${opts.uploadUrl}/${encodeURIComponent(item.meta.id)}`, { method: 'DELETE', credentials: 'same-origin', headers })) .catch(() => {}) } function dropItem(item) { if (item.xhr) item.xhr.abort() if (item.player) item.player.stop() if (item.url) URL.revokeObjectURL(item.url) } function removeItem(item) { const i = items.indexOf(item) if (i < 0) return items.splice(i, 1) dropItem(item) deleteOnServer(item) if (!items.some((x) => x.status === 'error')) hideNote() renderTray() pump() settle() } function upload(item) { if (destroyed || !items.includes(item)) return if (items.filter((i) => i.status === 'uploading').length >= MAX_PARALLEL_UPLOADS) { item.status = 'queued' item.waiting = true renderTray() return } item.waiting = false item.status = 'uploading' item.progress = 0 item.error = '' renderTray() extraHeaders().then( (headers) => { // Waehrend des Wartens entfernt oder Eingabezeile weg: nicht mehr hochladen if (destroyed || !items.includes(item)) return sendUpload(item, headers) }, (err) => { item.status = 'error' item.error = (err && err.message) || 'Anmeldung abgelaufen. Bitte Seite neu laden.' afterUpload(item) } ) } function sendUpload(item, headers) { const xhr = new XMLHttpRequest() item.xhr = xhr xhr.open('POST', opts.uploadUrl) for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value) xhr.setRequestHeader('Content-Type', 'application/octet-stream') xhr.setRequestHeader('X-File-Name', encodeURIComponent(item.name)) if (item.hint) xhr.setRequestHeader('X-Attachment-Kind', item.hint) if (item.durationMs) xhr.setRequestHeader('X-Duration-Ms', String(Math.round(item.durationMs))) if (item.width && item.height) { xhr.setRequestHeader('X-Image-Width', String(item.width)) xhr.setRequestHeader('X-Image-Height', String(item.height)) } xhr.upload.addEventListener('progress', (event) => { if (!event.lengthComputable) return item.progress = event.loaded / event.total if (item.bar) item.bar.style.width = `${Math.round(item.progress * 100)}%` }) xhr.addEventListener('load', () => { item.xhr = null let data = {} try { data = JSON.parse(xhr.responseText || '{}') } catch { data = {} } if (xhr.status >= 200 && xhr.status < 300 && data.attachment) { item.status = 'done' item.meta = data.attachment } else { item.status = 'error' item.error = data.error || (xhr.status === 413 ? 'Die Datei ist zu groß.' : `Hochladen fehlgeschlagen (Fehler ${xhr.status}).`) } afterUpload(item) }) xhr.addEventListener('error', () => { item.xhr = null item.status = 'error' item.error = 'Keine Verbindung zum Server.' afterUpload(item) }) xhr.send(item.file) renderTray() } // Naechsten wartenden Upload starten function pump() { const next = items.find((i) => i.waiting) if (next) upload(next) } function afterUpload(item) { pump() if (!items.includes(item)) { // Waehrend des Hochladens entfernt: offenen Anhang auf dem Server gleich wieder loeschen deleteOnServer(item) return } if (item.status === 'error') showNote(`„${item.name}“: ${item.error}`) renderTray() settle() } function trayItem(item) { let body if (item.kind === 'image' && item.url) { body = el('img', { src: item.url, alt: item.name }) } else if (item.kind === 'voice') { item.player = item.player || audioPlayer(item.url, { durationMs: item.durationMs, label: 'Sprachnachricht' }) body = item.player } else { body = [ el('span', { class: 'chat-file-icon' }, icon('file'), el('span', { class: 'chat-file-ext', text: extOf(item.name).toUpperCase().slice(0, 4) })), el('span', { class: 'chat-file-text' }, el('span', { class: 'chat-file-name', text: item.name }), el('span', { class: 'chat-file-size', text: item.status === 'error' ? item.error : fmtBytes(item.size) })), ] } item.bar = el('span', { class: 'chat-tray-progress', 'aria-hidden': 'true' }) item.bar.style.width = `${Math.round(item.progress * 100)}%` const shownKind = item.kind === 'image' && !item.url ? 'file' : item.kind return el('div', { class: `chat-tray-item is-${shownKind} is-${item.status}`, role: 'listitem', title: item.status === 'error' ? `${item.name}: ${item.error}` : item.name, }, body, item.bar, item.status === 'error' ? el('button', { type: 'button', class: 'chat-tray-retry', 'aria-label': `${item.name} erneut hochladen`, title: 'Erneut versuchen', onclick: () => upload(item) }, icon('retry')) : null, el('button', { type: 'button', class: 'chat-tray-remove', 'aria-label': `${item.name} entfernen`, title: 'Entfernen', onclick: () => removeItem(item) }, icon('close'))) } function renderTray() { tray.hidden = !items.length tray.replaceChildren(...items.map(trayItem)) updateSend() } // --------------------------------------------------------------- Senden -- function updateSend() { const busy = sending || sendQueued sendBtn.disabled = busy || !(textarea.value.trim() || items.length || recording) sendBtn.classList.toggle('is-busy', busy) const label = busy ? (uploading() || recording ? 'Lädt…' : 'Sendet…') : 'Senden' sendLabel.textContent = label sendBtn.setAttribute('aria-label', label) } /** Wartet eine Nachricht auf Uploads bzw. das Ende der Aufnahme, geht sie danach von selbst raus. */ function settle() { updateSend() if (!sendQueued || uploading() || recording) return sendQueued = false if (items.some((i) => i.status === 'error')) { showNote('Ein Anhang konnte nicht hochgeladen werden. Bitte entfernen oder erneut versuchen.') updateSend() return } doSend() } function requestSend() { if (sending || sendQueued) return if (recording) { sendQueued = true stopRecording('keep') updateSend() return } if (!textarea.value.trim() && !items.length) return if (items.some((i) => i.status === 'error')) { showNote('Ein Anhang konnte nicht hochgeladen werden. Bitte entfernen oder erneut versuchen.') return } if (uploading()) { sendQueued = true updateSend() return } doSend() } async function doSend() { const text = textarea.value.trim() const ready = items.filter((i) => i.status === 'done') if (!text && !ready.length) { updateSend() return } sending = true updateSend() hideNote() try { const message = await opts.send({ text, attachmentIds: ready.map((i) => i.meta.id) }) if (destroyed) return textarea.value = '' autoGrow() for (const item of ready) { const i = items.indexOf(item) if (i >= 0) items.splice(i, 1) if (item.player) item.player.stop() if (item.url) URL.revokeObjectURL(item.url) } renderTray() opts.onSent(message) } catch (err) { showNote((err && err.message) || 'Nachricht konnte nicht gesendet werden.') } finally { sending = false updateSend() if (!destroyed && !isTouch()) textarea.focus() } } function autoGrow() { textarea.style.height = 'auto' textarea.style.height = `${Math.min(textarea.scrollHeight + 2, 140)}px` } sendBtn.addEventListener('click', requestSend) textarea.addEventListener('input', () => { autoGrow() updateSend() }) textarea.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault() requestSend() } }) // Screenshots und kopierte Dateien direkt einfuegen textarea.addEventListener('paste', (event) => { const files = [...((event.clipboardData && event.clipboardData.files) || [])] if (!files.length) return event.preventDefault() addFiles(files) }) imageInput.addEventListener('change', () => { addFiles(imageInput.files) imageInput.value = '' }) fileInput.addEventListener('change', () => { addFiles(fileInput.files) fileInput.value = '' }) captureInput.addEventListener('change', () => { addFiles(captureInput.files, 'camera') captureInput.value = '' }) // Kamera: am Handy/Tablet die eingebaute Kamera-App, sonst die Kamera im Browser function takePhoto() { if (isMobileOs() || !(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) { captureInput.click() return } openCamera({ onPhoto: (file) => addFiles([file], 'camera'), onPickInstead: () => imageInput.click(), }) } // ------------------------------------------------------ Ziehen & Ablegen -- let dragDepth = 0 const hasFiles = (event) => [...((event.dataTransfer && event.dataTransfer.types) || [])].includes('Files') const onDragEnter = (event) => { if (!hasFiles(event)) return event.preventDefault() dragDepth += 1 zone.classList.add('chat-drop-active') } const onDragOver = (event) => { if (!hasFiles(event)) return event.preventDefault() event.dataTransfer.dropEffect = 'copy' } const onDragLeave = (event) => { if (!hasFiles(event)) return dragDepth = Math.max(0, dragDepth - 1) if (!dragDepth) zone.classList.remove('chat-drop-active') } const onDrop = (event) => { if (!hasFiles(event)) return event.preventDefault() dragDepth = 0 zone.classList.remove('chat-drop-active') addFiles(event.dataTransfer.files) } // Knapp daneben losgelassen wuerde der Browser die Datei selbst oeffnen und das Portal verlassen. // Andere Ablagen (z. B. Datei-Felder) bleiben unberuehrt. const onWindowDrag = (event) => { if (event.defaultPrevented || !hasFiles(event) || zone.contains(event.target)) return if (event.target && event.target.closest && event.target.closest('input[type=file]')) return event.preventDefault() if (event.type === 'dragover') event.dataTransfer.dropEffect = 'none' } zone.classList.add('chat-dropzone') zone.addEventListener('dragenter', onDragEnter) zone.addEventListener('dragover', onDragOver) zone.addEventListener('dragleave', onDragLeave) zone.addEventListener('drop', onDrop) window.addEventListener('dragover', onWindowDrag) window.addEventListener('drop', onWindowDrag) // ------------------------------------------------------ Sprachnachricht -- function paintLevels(levels) { const bars = recWave.children const offset = WAVE_BARS - levels.length for (let i = 0; i < WAVE_BARS; i += 1) { const level = i >= offset ? levels[i - offset] : 0 bars[i].style.transform = `scaleY(${Math.max(0.1, Math.min(1, level))})` } } async function startRecording() { if (recording || sending || sendQueued) return closeMenu() hideNote() micBtn.disabled = true let stream try { stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, }) } catch (err) { showNote(mediaErrorText(err, 'mic')) return } finally { micBtn.disabled = false } if (destroyed) { stopStream(stream) return } const mimeType = pickAudioType() let recorder try { recorder = new MediaRecorder(stream, mimeType ? { mimeType, audioBitsPerSecond: 48000 } : undefined) } catch { stopStream(stream) showNote('Sprachaufnahmen werden von diesem Browser nicht unterstützt.') return } const rec = { stream, recorder, chunks: [], startedAt: Date.now(), durationMs: 0, then: 'keep', timer: 0, meter: null, levels: [], finished: false } recorder.addEventListener('dataavailable', (event) => { if (event.data && event.data.size) rec.chunks.push(event.data) }) recorder.addEventListener('stop', () => finishRecording(rec)) recorder.start(1000) recording = rec composer.classList.add('is-recording') recTime.textContent = '0:00' paintLevels([]) rec.timer = setInterval(() => { const ms = Date.now() - rec.startedAt recTime.textContent = fmtDuration(ms, Math.floor) if (ms >= MAX_VOICE_MS) { stopRecording('keep') showNote('Die Aufnahme wurde nach 5 Minuten beendet. Sie kann jetzt angehört und gesendet werden.', { info: true }) } }, 250) rec.meter = startMeter(stream, (level) => { rec.levels.push(level) if (rec.levels.length > WAVE_BARS) rec.levels.shift() paintLevels(rec.levels) }) updateSend() recStop.focus() } function stopRecording(then) { const rec = recording if (!rec) return rec.then = then rec.durationMs = Date.now() - rec.startedAt clearInterval(rec.timer) if (rec.recorder.state === 'inactive') { finishRecording(rec) return } try { rec.recorder.stop() } catch { finishRecording(rec) } } function finishRecording(rec) { if (rec.finished) return rec.finished = true clearInterval(rec.timer) stopStream(rec.stream) if (rec.meter) rec.meter.stop() if (recording === rec) recording = null composer.classList.remove('is-recording') if (destroyed) return // Endet die Aufnahme von selbst (Headset getrennt, Anruf, Zugriff entzogen), wurde keine Dauer gesetzt if (!rec.durationMs) rec.durationMs = Date.now() - rec.startedAt if (rec.then === 'keep') { if (rec.durationMs < MIN_VOICE_MS || !rec.chunks.length) { showNote('Die Aufnahme war zu kurz.') } else { const type = rec.recorder.mimeType || rec.chunks[0].type || 'audio/webm' const ext = /mp4|aac/.test(type) ? 'm4a' : /ogg/.test(type) ? 'ogg' : 'webm' const blob = new Blob(rec.chunks, { type: type.split(';')[0] }) addItem({ file: blob, name: `Sprachnachricht_${stamp()}.${ext}`, kind: 'voice', hint: 'voice', durationMs: rec.durationMs }) } } settle() if (!isTouch()) (sendQueued ? sendBtn : textarea).focus() } micBtn.addEventListener('click', startRecording) recStop.addEventListener('click', () => stopRecording('keep')) recDiscard.addEventListener('click', () => { sendQueued = false stopRecording('discard') }) function destroy() { destroyed = true if (recording) stopRecording('discard') for (const item of items.splice(0)) { dropItem(item) if (item.status === 'done') deleteOnServer(item) } document.removeEventListener('click', onDocClick) document.removeEventListener('keydown', onDocKey) zone.removeEventListener('dragenter', onDragEnter) zone.removeEventListener('dragover', onDragOver) zone.removeEventListener('dragleave', onDragLeave) zone.removeEventListener('drop', onDrop) window.removeEventListener('dragover', onWindowDrag) window.removeEventListener('drop', onWindowDrag) zone.classList.remove('chat-dropzone', 'chat-drop-active') root.replaceChildren() } updateSend() return { destroy, focus: () => textarea.focus() } } window.ChatComposer = { create, renderMessage, trackScroll } })()