2026-11-07 19:59:47 +00:00
const CHANNELS = [... TO_ONE , ... TO_ALL ];
const CHANNEL_HINTS = {
2026-11-08 17:26:51 +00:00
post : "Post arrives at the end of the turn." ,
call : "Call arrives at once, with a read receipt." ,
newsletter : "Newsletter arrives as post to all, at the end of their turn." ,
broadcast : "Broadcast reaches all at once." ,
"pan-pan" : "PAN-PAN: urgent, to all, at once." ,
security : "Sécurité: safety notice to all stations, now." ,
mayday : "MAYDAY: all agents pause writing steps until you cancel it." ,
2026-11-07 19:59:47 +00:00
};
const VOICE_POLL = 700 ;
const composer = { voice : "" , recorder : null , timer : 0 , started : 0 };
function confirmMayday () {
2026-11-08 17:26:51 +00:00
return window . confirm ( "Send a mayday to all? All agents pause writing steps until you cancel it." );
2026-11-07 19:59:47 +00:00
}
async function radioSend ( message ) {
2026-11-08 17:26:51 +00:00
const label = ` ${ CLASSES [ message . cls ] || message . cls } to ${ message . to === "*" ? "all" : message . to } ` ;
2026-11-07 19:59:47 +00:00
const fields = { to : message . to , text : message . text , upload : message . upload , class : message . cls , voice : message . voice };
const signed = await signedProof ( "/api/messages/sign" , fields , label );
if ( ! signed ) {
return false ;
}
const { proof } = signed ;
const result = message . to === "*"
? await sendJson ( "/api/board/broadcast" , { text : fields . text , class : fields . class , voice : fields . voice , proof })
: await sendJson ( "/api/messages" , {... fields , proof });
if ( ! result . ok || ! result . data ) {
2026-11-08 17:26:51 +00:00
toast ( ` ${ label } failed` , fail ( result ), false );
2026-11-07 19:59:47 +00:00
return false ;
}
2026-11-08 17:26:51 +00:00
toast ( ` ${ label } : ${ result . data . ok ? "sent" : "stored, sending failed" } ` , [...( result . data . id ? [ `kvb-orchestrator msg ${ result . data . id } ` ] : []), ... runLines ( result . data )], result . data . ok );
2026-11-07 19:59:47 +00:00
await load ();
return true ;
}
function attachVoice ( parts , note ) {
composer . voice = note ;
parts . attach . checked = false ;
parts . replay . hidden = ! note ;
2026-11-08 17:26:51 +00:00
parts . replay . replaceChildren (...( note ? [ icon ( "bi-soundwave" ), h ( "span" , { class : "muted" }, "Voice message" ), voiceReplay ( note ), h ( "label" , { class : "check" }, parts . attach , "Attach recording" ), iconBtn ( "bi-trash" , "Remove recording" , "btn small ghost" , () => attachVoice ( parts , "" ))] : []));
2026-11-07 19:59:47 +00:00
}
async function heardNote ( note ) {
const until = Date . now () + ( state . speech . seconds + 60 ) * 1000 ;
while ( Date . now () < until ) {
const response = await fetch ( `/api/voice/ ${ note } ` , { cache : "no-store" });
const data = response . ok ? await response . json () : { status : "failed" , error : ` ${ response . status } ${ ( await response . text ()). trim () } ` };
if ( data . status !== "pending" ) {
return data ;
}
await new Promise (( resolve ) => window . setTimeout ( resolve , VOICE_POLL ));
}
2026-11-08 17:26:51 +00:00
return { status : "failed" , error : "no transcript in the expected time" };
2026-11-07 19:59:47 +00:00
}
async function keepRecording ( blob , parts ) {
parts . record . disabled = true ;
try {
const sound = context ();
if ( ! sound || ! blob . size ) {
2026-11-08 17:26:51 +00:00
throw new Error ( "no recording" );
2026-11-07 19:59:47 +00:00
}
2026-11-08 17:26:51 +00:00
parts . status . textContent = "Converting …" ;
2026-11-07 19:59:47 +00:00
const stored = await send ( "/api/voice" , await wavOf ( blob , sound ), "audio/wav" );
if ( ! stored . ok || ! stored . data ) {
throw new Error ( ` ${ stored . status } ${ stored . text } ` . trim ());
}
attachVoice ( parts , stored . data . id );
2026-11-08 17:26:51 +00:00
parts . status . textContent = "Transcribing …" ;
2026-11-07 19:59:47 +00:00
const note = await heardNote ( stored . data . id );
if ( note . status === "failed" ) {
2026-11-08 17:26:51 +00:00
parts . status . textContent = "No transcript: write the text yourself." ;
toast ( "Transcript failed" , [ note . error ], false );
2026-11-07 19:59:47 +00:00
return ;
}
parts . text . value = [ parts . text . value . trim (), note . text ]. filter ( Boolean ). join ( " " );
parts . text . dispatchEvent ( new Event ( "input" , { bubbles : true }));
parts . text . focus ();
2026-11-08 17:26:51 +00:00
parts . status . textContent = note . text ? "Check the text, then send." : "Nothing understood: write the text yourself." ;
2026-11-07 19:59:47 +00:00
} catch ( error ) {
parts . status . textContent = "" ;
2026-11-08 17:26:51 +00:00
toast ( "Recording failed" , [ String ( error . message || error )], false );
2026-11-07 19:59:47 +00:00
} finally {
parts . record . disabled = false ;
}
}
function recordLabel ( parts , on ) {
parts . record . setAttribute ( "aria-pressed" , String ( on ));
2026-11-08 17:26:51 +00:00
parts . record . replaceChildren ( icon ( on ? "bi-stop-fill" : "bi-record-circle" ), on ? "Stop" : "Record" );
2026-11-07 19:59:47 +00:00
}
function stopRecording () {
if ( composer . recorder && composer . recorder . state === "recording" ) {
composer . recorder . stop ();
}
}
async function startRecording ( parts ) {
let media = null ;
try {
media = await navigator . mediaDevices . getUserMedia ({ audio : { channelCount : 1 , echoCancellation : true , noiseSuppression : true }});
} catch ( error ) {
2026-11-08 17:26:51 +00:00
toast ( "Microphone unavailable" , [ String ( error . message || error )], false );
2026-11-07 19:59:47 +00:00
return ;
}
const recorder = new MediaRecorder ( media );
const chunks = [];
recorder . addEventListener ( "dataavailable" , ( event ) => {
if ( event . data . size ) {
chunks . push ( event . data );
}
});
recorder . addEventListener ( "stop" , () => {
media . getTracks (). forEach (( track ) => track . stop ());
window . clearInterval ( composer . timer );
composer . recorder = null ;
recordLabel ( parts , false );
keepRecording ( new Blob ( chunks , { type : recorder . mimeType }), parts );
}, { once : true });
Object . assign ( composer , { recorder , started : Date . now ()});
recorder . start ();
recordLabel ( parts , true );
composer . timer = window . setInterval (() => {
const seconds = Math . floor (( Date . now () - composer . started ) / 1000 );
2026-11-08 17:26:51 +00:00
parts . status . textContent = `Recording ${ Math . floor ( seconds / 60 ) } : ${ String ( seconds % 60 ). padStart ( 2 , "0" ) } of at most ${ state . speech . seconds } s` ;
2026-11-07 19:59:47 +00:00
if ( seconds >= state . speech . seconds ) {
stopRecording ();
}
}, 250 );
}
function composerForm () {
const channel = h ( "select" , { class : "field" }, CHANNELS . map (([ id , label ]) => h ( "option" , { value : id }, label )));
const recipient = h ( "select" , { class : "field" });
2026-11-08 17:26:51 +00:00
const toField = h ( "label" , {}, "To" , recipient );
const text = h ( "textarea" , { "aria-label" : "Message" , placeholder : "Message to the agents …" , maxlength : 20000 });
2026-11-07 19:59:47 +00:00
const replay = h ( "div" , { class : "voice-note" , hidden : true });
const hint = h ( "p" , { class : "muted" });
const status = h ( "span" , { class : "muted composer-status" , "aria-live" : "polite" });
2026-11-08 17:26:51 +00:00
const record = h ( "button" , { type : "button" , class : "btn small" , "aria-pressed" : "false" , title : "Record: one click starts, another stops; the hub transcribes, you check the text before sending" });
const submit = h ( "button" , { type : "submit" , class : "btn primary" }, icon ( "bi-send" ), "Send" );
const attach = h ( "input" , { type : "checkbox" , title : "Attach recording: the agents get the path of the sound file; otherwise only the checked text goes out" });
2026-11-07 19:59:47 +00:00
const parts = { record , status , text , replay , attach };
recordLabel ( parts , false );
const gap = micGap ();
if ( gap ) {
record . disabled = true ;
2026-11-08 17:26:51 +00:00
record . title = `Record: ${ gap } ` ;
2026-11-07 19:59:47 +00:00
}
record . addEventListener ( "click" , () => ( composer . recorder ? stopRecording () : startRecording ( parts )));
const sync = () => {
const direct = TO_ONE . some (([ id ]) => id === channel . value );
toField . hidden = ! direct ;
2026-11-08 17:26:51 +00:00
hint . textContent = ` ${ CHANNEL_HINTS [ channel . value ] } Your passkey signs every message.` ;
2026-11-07 19:59:47 +00:00
};
channel . addEventListener ( "change" , sync );
sync ();
2026-11-08 17:26:51 +00:00
const form = h ( "form" , { class : "composer" }, h ( "div" , { class : "composer-grid" }, h ( "label" , {}, "Channel" , channel ), toField ), text , replay , hint ,
2026-11-07 19:59:47 +00:00
h ( "div" , { class : "actions" }, record , status , h ( "span" , { class : "spacer" }), submit ));
form . addEventListener ( "submit" , async ( event ) => {
event . preventDefault ();
const direct = TO_ONE . some (([ id ]) => id === channel . value );
if ( ! text . value . trim ()) {
2026-11-08 17:26:51 +00:00
toast ( "Nothing to send" , [ "Write a text or record one; the transcript lands in the field for checking." ], false );
2026-11-07 19:59:47 +00:00
return ;
}
if (( direct && ! recipient . value ) || ( channel . value === "mayday" && ! confirmMayday ())) {
return ;
}
submit . disabled = true ;
if ( await radioSend ({ to : direct ? recipient . value : "*" , text : text . value , upload : "" , cls : channel . value , voice : attach . checked ? composer . voice : "" })) {
text . value = "" ;
status . textContent = "" ;
attachVoice ( parts , "" );
}
submit . disabled = false ;
});
attachVoice ( parts , composer . voice );
return { form , recipient };
}