function createVoiceConversationCapture({ Recognition, elements, transcriptStore = null, getLogin = () => '', createEvent = name => new Event(name, {bubbles:true}), maxLength = 0, draftLabel = '', }) { const supported = typeof Recognition === 'function'; const label = String(draftLabel || elements.root.dataset?.draftLabel || 'reply').trim() || 'reply'; let recognition = null; let target = ''; let generation = 0; elements.root.hidden = !supported; if (!supported) { elements.status.textContent = 'Voice capture is unavailable; type the ' + label + ' instead.'; } const normalize = value => String(value || '').replace(/\s+/g, ' ').trim(); const login = () => String(getLogin() || '').trim(); const composerLimit = Number(elements.draft.maxLength); const limit = Number.isInteger(maxLength) && maxLength > 0 ? maxLength : Number.isInteger(composerLimit) && composerLimit > 0 ? composerLimit : 10000; function showReview(value, recovered = false) { const transcript = normalize(value).slice(0, limit); elements.transcript.value = transcript; elements.review.hidden = false; elements.append.textContent = elements.draft.value.trim() ? 'Append to draft' : 'Use transcript'; elements.replace.hidden = !elements.draft.value.trim(); elements.status.textContent = recovered ? 'Recovered transcript. Review it before using or discarding it.' : 'Transcript ready. Review it before using it.'; if (!recovered && transcriptStore && login() && target) { void transcriptStore.save(login(), transcript, target); } } async function open(nextTarget) { cancel(); target = String(nextTarget || '').trim(); const opening = ++generation; elements.transcript.value = ''; elements.review.hidden = true; elements.status.textContent = ''; if (!supported || !target || !transcriptStore || !login()) return; const recovered = normalize(await transcriptStore.load(login(), target)).slice(0, limit); if (opening === generation && recovered) showReview(recovered, true); } function clearCheckpoint() { if (transcriptStore && login() && target) void transcriptStore.clear(login(), target); } function commit(replace) { const transcript = normalize(elements.transcript.value).slice(0, limit); elements.draft.value = replace ? transcript : [elements.draft.value.trim(), transcript].filter(Boolean).join('\n\n').slice(0, limit); elements.draft.dispatchEvent(createEvent('input')); clearCheckpoint(); elements.review.hidden = true; elements.status.textContent = 'Transcript added. Review the ' + label + ' before sending.'; } elements.start.addEventListener('click', () => { if (!supported || !target) return; const sessionGeneration = generation; const currentRecognition = new Recognition(); recognition = currentRecognition; recognition.continuous = true; recognition.interimResults = false; recognition.onresult = event => { if (recognition !== currentRecognition || generation !== sessionGeneration) return; const finalText = Array.from(event.results || []).slice(event.resultIndex ?? 0) .filter(result => result.isFinal) .map(result => result[0]?.transcript || '').join(' '); if (normalize(finalText)) { showReview([elements.transcript.value, finalText].filter(Boolean).join(' ')); } }; recognition.onerror = event => { if (recognition !== currentRecognition || generation !== sessionGeneration) return; elements.start.hidden = false; elements.stop.hidden = true; elements.status.textContent = ['not-allowed', 'service-not-allowed'].includes(event.error) ? 'Microphone permission was denied. Your draft and transcript are unchanged.' : 'Voice capture stopped. Your draft and transcript are unchanged.'; }; recognition.onend = () => { if (recognition !== currentRecognition || generation !== sessionGeneration) return; recognition = null; elements.start.hidden = false; elements.stop.hidden = true; }; try { recognition.start(); } catch (_) { recognition = null; elements.start.hidden = false; elements.stop.hidden = true; elements.status.textContent = 'Voice capture could not start. Your draft and transcript are unchanged.'; return; } elements.start.hidden = true; elements.stop.hidden = false; elements.status.textContent = 'Listening… Tap stop when you are finished.'; }); elements.stop.addEventListener('click', () => { recognition?.stop(); elements.status.textContent = 'Finishing transcript…'; }); elements.append.addEventListener('click', () => commit(false)); elements.replace.addEventListener('click', () => commit(true)); elements.discard.addEventListener('click', () => { clearCheckpoint(); elements.transcript.value = ''; elements.review.hidden = true; elements.status.textContent = 'Transcript discarded.'; }); function cancel() { generation += 1; recognition?.abort(); recognition = null; elements.start.hidden = false; elements.stop.hidden = true; } return {supported, open, cancel}; } function mountVoiceConversation({ kind, draftSelector, qs, Recognition, transcriptStore, getLogin, createCapture = createVoiceConversationCapture, }) { return createCapture({ Recognition, transcriptStore, getLogin, elements:{ root:qs('#voice-' + kind), start:qs('#start-voice-' + kind), stop:qs('#stop-voice-' + kind), review:qs('#voice-' + kind + '-review'), transcript:qs('#voice-' + kind + '-transcript'), append:qs('#append-voice-' + kind), replace:qs('#replace-with-voice-' + kind), discard:qs('#discard-voice-' + kind), status:qs('#voice-' + kind + '-status'), draft:qs(draftSelector), }, }); } if (typeof module !== 'undefined') module.exports = {createVoiceConversationCapture, mountVoiceConversation};