/* 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 = ``
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 }
})()