/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
// Global vars
// call_url and is_host should be set by index.php
var answer = false;
var peer_connection = null
var local_stream = null;
var remote_stream = null;
var event_source = null;
var message_socket = null;
var local_av = null;
var remote_av = null;
var configuration = null;
var calling_timer_id = -1;
var have_set_local_ice_candidate = false;
var call_state = null;
var old_call_state = null;
var call_toggle_color = null;
var sent_candidate = null;
var no_answer_id = null;
/*
 * When window.call_trace is on, callTrace writes each step of a call to
 * the browser console with a running clock and the channel a step came
 * over, so a call placed between two browsers can be read side by side
 * to see where it stops. The controller turns it on for the page when
 * the site's debug level asks for it; a person can also set
 * window.call_trace = true in the console before placing a call.
 */
var call_trace_start = 0;
/*
 * callTrace writes one line of a call's progress to the console when
 * tracing is on. The line carries the seconds since the first traced
 * step, which channel the step used (websocket, eventsource, fetch, or
 * a dash where none applies), the step's name, and anything more the
 * caller passes. Reading two browsers' consoles together shows which
 * side sent an offer, whether the other read it, and where a call that
 * never connects stops.
 *
 * @param String channel which channel carried the step, one of
 *      websocket, eventsource, fetch, or "-" for a local step
 * @param String step the name of the step, such as offer-sent or
 *      answer-read
 * @param Object detail anything more to show, left out where there is
 *      none
 */
function callTrace(channel, step, detail)
{
    if (!window.call_trace) {
        return;
    }
    if (!call_trace_start) {
        call_trace_start = Date.now();
    }
    var seconds = ((Date.now() - call_trace_start) / 1000).toFixed(2);
    var line = "[call " + seconds + "s " + channel + "] " + step;
    if (detail !== undefined) {
        console.log(line, detail);
    } else {
        console.log(line);
    }
}
/**
 * Lets go of the channels a conversation listens on, leaving the page
 * as it stands. A browser leaving a page may suspend it rather than
 * take it down, and a socket still open at that moment is reported as
 * closed by suspension, so the channels are let go while the page is
 * still there to do it.
 */
function closeMessageChannels()
{
    if (message_socket) {
        message_socket.close();
        message_socket = null;
    }
    if (event_source) {
        event_source.close();
        event_source = null;
    }
}
/**
 * Stops listening for new messages and replaces the conversation with
 * a notice saying so; triggered by the twenty-minute idle timeout in
 * doUpdate.
 */
function clearUpdate()
{
     closeMessageChannels();
     elt('conversation').innerHTML = "<h2 class='red'>" +
        tl['social_component_no_longer_update'] + '</h2>';
}
/**
 * Resets the conversation pane's background to white; used to
 * clear the "new message" highlight color.
 */
function resetBackground()
{
     elt('conversation').style.backgroundColor = "#FFF";
}
/**
 * Opens a new EventSource against start_url, wires it up to
 * sendMessage / handleMessage, and arms the 20-minute clearUpdate
 * timeout. Called on page load and whenever the stream needs to
 * be re-opened.
 */
function doUpdate()
{
    var sec = 1000;
    var minute = 60 * sec;
    var conversation_elt = elt('conversation-header');
    if (conversation_elt) {
        call_toggle_color = conversation_elt.style.backgroundColor;
    }
    /*
       When the server has handed us a WebSocket address it can hold a
       socket open, so use it: new messages arrive the moment they are
       sent instead of on the event-stream reconnect cycle. The socket
       feeds the same handleMessage the event stream does, since both
       carry the same status envelope. If the socket cannot be opened or
       the server gave no address, fall back to the EventSource.
     */
    if (window.ws_messages_url) {
        try {
            message_socket = new WebSocket(window.ws_messages_url);
            message_socket.onmessage = handleMessage;
            message_socket.onopen = answerWaitingCall;
            callTrace("websocket", "channel-opening",
                window.ws_messages_url);
            message_socket.onerror = function ()
            {
                /* A socket that opened and then failed is closed rather
                   than left beside a stream, and the fall back is taken
                   once. Opening a stream on every error left several
                   running at once, each answering with the whole
                   conversation and each holding a connection open. */
                if (message_socket &&
                    message_socket.readyState === WebSocket.OPEN) {
                    message_socket.close();
                }
                message_socket = null;
                callTrace("websocket", "channel-error-falling-back");
                openMessageEventSource();
            };
        } catch (problem) {
            openMessageEventSource();
        }
    } else {
        openMessageEventSource();
    }
    setCallState(null, '');
    /* A screen opened by the answer button takes the call when its
       channel opens. Where no channel opens at all it would sit holding a
       call it never answered, so it takes the call anyway after a wait:
       the offer goes by its own request, and a channel coming up later
       carries the reply. */
    if (window.answer_waiting_call) {
        answer_wait_timer = setTimeout(answerWaitingCall,
            ANSWER_CHANNEL_WAIT);
    }
    /* A browser leaving a page may suspend it with its socket still
       open, which it then reports as closed by suspension. Letting the
       channels go on the way out avoids that. */
    /* A screen leaving a call says so on the way out, so the call is
       over for the other side at once rather than after the wait for a
       call that has gone quiet. A reload counts as leaving. */
    window.addEventListener("pagehide", function ()
    {
        if (call_state && call_state != 'video-end' &&
            call_state != 'video-end-received') {
            publish('call-end', null);
        }
        closeMessageChannels();
    });
    setTimeout("clearUpdate()", 20 * minute + sec);
}
/**
 * Opens the message-stream EventSource against start_url and wires it to
 * sendMessage / handleMessage. This is the fallback used when the server
 * cannot hold a WebSocket open, and the original always-available path.
 */
function openMessageEventSource()
{
    /* One stream at a time. A second one answers with the same
       conversation as the first, so its messages would arrive twice, and
       it holds another connection open against the server's limit. */
    if (event_source) {
        return;
    }
    try {
        event_source = new EventSource(start_url);
        event_source.onmessage = handleMessage;
        event_source.onopen = answerWaitingCall;
        callTrace("eventsource", "channel-opened", start_url);
    } catch (problem) {
        console.error("Could not create eventsource.", problem);
    }
}
/**
 * sayCallingIsDown tells the reader that calls cannot be placed, and why.
 *
 * The call control on a conversation runs this in place of starting a
 * call where the site handed the page no relay to carry one. A relay is
 * what lets two browsers on different home networks reach each other, so
 * without one a call rings and then fails. Saying so at the press is
 * clearer than a control that does nothing.
 */
function sayCallingIsDown()
{
    let words = (typeof tl == "object" &&
        tl['usermessages_element_calling_down']) ?
        tl['usermessages_element_calling_down'] :
        "Video calling is down: no relay answered.";
    let said = elt('call-trouble');
    if (said) {
        said.innerHTML = words;
        setDisplay('call-trouble', true);
        return;
    }
    alert(words);
}
/**
 * Transitions the per-tab call state machine to $state, kicking
 * off / tearing down audio elements, ringtones, and the AV UI as
 * appropriate.
 *
 * @param {string|null} state new call state ("calling", "video",
 *      "audio", "video-end", "video-end-received", or null/empty
 *      to reset)
 */
function setCallState(state)
{
    if (state == 'video-end') {
        callTrace("-", "hang up asked for here");
    }
    if (state && state != 'video-end' && state != 'video-end-received' &&
        (!call_state || call_state == 'video-end' ||
        call_state == 'video-end-received')) {
        call_trace_start = Date.now();
    }
    old_call_state = call_state;
    call_state = state;
    if (state && state != 'video-end' && state != 'video-end-received') {
        /* Every attempt at a call starts unproven, whatever the screen
           was doing before. Keeping the mark from an earlier attempt
           let an answer about the moment before this one began end it
           at once, which is what happened when a call that had failed
           was placed again. */
        window.call_started_at = Date.now();
        window.call_seen_live = false;
        window.call_placed_here = (old_call_state != 'calling' &&
            old_call_state != 'client-call');
    }
    console.log("state:" + call_state + "; old_state:" + old_call_state);
    if (state) {
        let relays = "no relay configured";
        if (configuration && configuration.iceServers) {
            let named = [];
            for (let i = 0; i < configuration.iceServers.length; i++) {
                named.push(configuration.iceServers[i].urls);
            }
            relays = named.length + " relay(s): " + named.join(", ");
        }
        callTrace("-", "state " + state, relays);
    }
    if (!event_source && !message_socket) {
        console.log("setCallState: no message channel open");
    }
    if (call_state && call_state != 'video-end' &&
        call_state != 'video-end-received') {
        if (!old_call_state || old_call_state != 'calling') {
            startCallSound();
        }
        if (call_state == 'video') {
            elt('av-call').innerHTML = `
            <div id="av-call-div">🎦</div>
            <video id='local-av' autoplay='true' muted></video>
            <video id='remote-av' autoplay='true' ></video>
            `;
            setDisplay('video-start', false);
            setDisplay('video-end', true, 'inline-flex');
        }
        setDisplay('av-call', true);
        setDisplay('av-call-div', true);
        setDisplay('conversation', false);
        setDisplay('new-message-container', false);
        no_answer_id = setTimeout(cancelCallSound, 60000);
        local_av = elt('local-av');
        remote_av = elt('remote-av');
        var constraints = {
            'video' : true,
            'audio' : true
        };
        if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
          alert('Your browser does not support getUserMedia API');
          return;
        }
        navigator.mediaDevices.getUserMedia(constraints).then( (stream) => {
            console.log('Got MediaStream: ', stream);
            local_av.srcObject = stream;
            local_stream = stream;
            if (old_call_state == 'calling') {
                makeOffer();
            } else {
                local_av.onloadedmetadata = () => {
                    publish('client-call', null);
                };
            }
        }).catch (error => {
            console.error('Error accessing media devices.', error);
        });
    } else {
        cancelCallSound();
        if (local_stream) {
            local_stream.getTracks().forEach(track => track.stop());
        }
        if (remote_stream) {
            remote_stream.getTracks().forEach(track => track.stop());
        }
        /* Close the peer connection and clear the flags that stand for
           one call, so the next call builds a fresh connection rather
           than reaching for this one, which is now closed. Leaving them
           set made a second call reuse a closed connection: the guard in
           icecandidate saw the flag still true and returned without
           building a new one, and the stale connection then threw an
           invalid-state error on the first thing asked of it. */
        if (peer_connection) {
            peer_connection.close();
        }
        peer_connection = null;
        have_set_local_ice_candidate = false;
        answer = false;
        sent_candidate = null;
        local_stream = null;
        remote_stream = null;
        local_av = null;
        remote_av = null;
        if (call_state != 'video-end-received' && old_call_state &&
            old_call_state != 'video-end' &&
            old_call_state != 'video-end-received') {
            /* Ending is only said when a call was in progress. The reset
               every page load runs through here too, and telling the
               server a call ended then cost a request whose answer was
               the whole conversation. */
            publish('call-end', null);
        }
        var av_call = elt('av-call');
        if (av_call) {
            av_call.innerHTML = '';
        }
        setDisplay('video-start', true, 'inline-flex');
        setDisplay('video-end', false);
        setDisplay('av-call', false);
        setDisplay('conversation', 'flex');
        setDisplay('new-message-container', true);
    }
}
/**
 * Starts the ringback tone (looping) and the "calling" header
 * flash, both retained until cancelCallSound is invoked.
 */
function startCallSound()
{
    clearInterval(calling_timer_id);
    let phone_sound = elt('phone-sound');
    if (phone_sound) {
        phone_sound.loop = true;
        phone_sound.play();
    }
    calling_timer_id = setInterval(() => {
        let call_toggle = elt('conversation-header');
        if (!call_toggle) {
            return;
        }
        let toggle_color = call_toggle.style.backgroundColor;
        call_toggle.style.backgroundColor = (toggle_color == "lightblue") ?
            call_toggle_color : "lightblue";
    }, 500);
}
/**
 * Stops the ringback tone, resets the header background, and
 * clears the flashing interval set up by startCallSound.
 */
function cancelCallSound()
{
    clearInterval(calling_timer_id);
    call_toggle = elt('conversation-header');
    if (call_toggle) {
        call_toggle.style.backgroundColor = call_toggle_color;
    }
    let phone_sound = elt('phone-sound');
    if (phone_sound) {
        /* Winding the sound back is done by setting the time rather than
           by fastSeek, which only one browser family has. Calling the
           missing one threw before the call was placed, so pressing call
           rang nobody and left the caller no way to stop. */
        phone_sound.currentTime = 0;
        phone_sound.pause();
    }
}
/**
 * JSON-POSTs $json to $url and passes the response body stream
 * to $callback when the request completes. Wraps fetch() so
 * callers don't have to repeat the boilerplate.
 *
 * @param {string} url request target
 * @param {object} json JSON-serializable body
 * @param {Function} callback invoked with the response body
 *      (ReadableStream) on completion
 */
async function post(url, json, callback)
{
    const response = await fetch(url, {
        method: "post",
        body: JSON.stringify(json),
        headers: {"Content-Type": "application/json"},
    });
    callback(response.body);
}
/**
 * Sends a {type, data} envelope back up the EventSource to the
 * server (which then routes it to the other call participant via
 * GROUP_CALL_EVENTS).
 *
 * @param {string} type event type tag (e.g. "offer", "answer",
 *      "candidate", "call-end")
 * @param {*} data event payload
 */
function publish(type, data)
{
    sendMessage({
        type: type,
        data: data
    });
}

/**
 * EventSource onmessage handler: parses the envelope and
 * dispatches to the type-specific handler from the
 * allowed_messages map (status / call-end / client-answer /
 * client-candidate / client-call / client-offer).
 *
 * @param {MessageEvent} message
 */
function handleMessage(message)
{
    let package = JSON.parse(message.data);
    let data = package.data;
    let type = package.type;
    let over = (message.target instanceof WebSocket) ?
        "websocket" : "eventsource";
    if (type != 'status') {
        callTrace(over, "read " + type);
    }
    let allowed_messages = {
        status : handleStatus,
        'call-end' :  handleCallEnd,
        'client-answer': handleClientAnswer,
        'client-call': handleClientCall,
        'client-candidate': handleClientCandidate,
        'client-offer': handleClientOffer,
    };
    if (allowed_messages.hasOwnProperty(type)) {
        let handler = allowed_messages[type];
        handler(type, data);
    } else {
        console.error("messages.js can't handle message of type:" +
            type);
    }
}
/**
 * WebRTC client-answer handler: applies the remote SDP answer to
 * the existing peer_connection (which must already have been
 * created when the local side sent the offer).
 *
 * @param {string} type the event-source envelope type tag
 * @param {RTCSessionDescriptionInit} data SDP answer
 */
function handleClientAnswer(type, data)
{
    if (peer_connection == null) {
        console.error('Before processing the client-answer, ' +
            'I need a client-offer');
        return;
    }
    console.log("handleClientAnswer method");
    window.call_answered = true;
    peer_connection.setRemoteDescription(
        new RTCSessionDescription(data)).catch(function (event) {
        console.log("Problem while doing client-answer: ", event);
    });
}
/**
 * WebRTC ICE-candidate handler: adds a remote ICE candidate to
 * the existing peer_connection.
 *
 * @param {string} type the event-source envelope type tag
 * @param {RTCIceCandidateInit} data ICE candidate description
 */
function handleClientCandidate(type, data)
{
    if (peer_connection == null) {
        console.error('Before processing the client-candidate, '+
            'I need a client-offer');
        return;
    }
    console.log("handleClientCandidate method");
    if (!data || !data.candidate) {
        return;
    }
    peer_connection.addIceCandidate(
        new RTCIceCandidate(data)).catch(function (event) {
        console.log("Problem adding ice candidate: " + event);
    });
}
/**
 * Remote-end call-termination handler: transitions the call
 * state to "video-end-received" so the local UI tears down the
 * AV elements.
 *
 * @param {string} type the event-source envelope type tag
 * @param {*} data event payload (not used)
 */
function handleCallEnd(type, data)
{
    console.log("handleCallEnd method");
    call_state = type;
    setCallState('video-end-received');
}
/**
 * How long, in thousandths of a second, a screen opened by the answer
 * button waits for its message channel before taking the call anyway. A
 * channel opens in well under this where it opens at all.
 */
const ANSWER_CHANNEL_WAIT = 4000;
/**
 * Holds that wait so taking the call on the channel opening can stop it,
 * and the call is taken once rather than twice.
 */
let answer_wait_timer = null;
/**
 * answerWaitingCall takes a call that is already ringing, as though the
 * person had pressed the answer control in the conversation.
 *
 * The channel that carries call events calls this as soon as it is open,
 * whether that channel is a socket or a stream. The screen reached by the
 * answer button in a contact row is a fresh page, so it has no memory that
 * a call was waiting, and the other side replies over that channel, so
 * answering before it is open loses the reply and no picture starts.
 */
function answerWaitingCall()
{
    if (!window.answer_waiting_call) {
        return;
    }
    clearTimeout(answer_wait_timer);
    window.answer_waiting_call = false;
    old_call_state = 'client-call';
    call_state = 'calling';
    setCallState('video');
}
/**
 * Incoming-call handler: plays the ringtone and shows the
 * accept/decline UI so the local user can answer.
 *
 * @param {string} type the event-source envelope type tag
 * @param {*} data event payload (not used)
 */
function handleClientCall(type, data)
{
    console.log("handleClientCall method");
    startCallSound();
    old_call_state = type;
    call_state = 'calling';
    setDisplay('video-start', true, 'inline-flex');
    setDisplay('video-end', true, 'inline-flex');
}
/**
 * Locally creates a WebRTC SDP offer (with audio+video receive
 * intent), sets it as the local description, and publishes it to
 * the remote peer over the message channel.
 */
function makeOffer()
{
    window.call_answered = false;
    window.call_offered_at = Date.now();
    icecandidate(local_stream);
    peer_connection.createOffer({
        offerToReceiveAudio: 1,
        offerToReceiveVideo: 1
    }).then(function (description) {
        peer_connection.setLocalDescription(description).then(
            function () {
                publish('client-offer',
                    peer_connection.localDescription);
            }
        ).catch(function (event) {
            console.log("Problem with publishing client offer: " + event);
            return;
        });
    }).catch(function (event) {
        console.log("Problem while doing client-call: " + event);
        return;
    });
}
/**
 * WebRTC client-offer handler: applies the remote SDP offer,
 * arms ICE for the local stream, and publishes an SDP answer
 * back to the caller.
 *
 * @param {string} type the event-source envelope type tag
 * @param {RTCSessionDescriptionInit} data SDP offer
 */
function handleClientOffer(type, data)
{
    icecandidate(local_stream);
    /* The promise forms are used throughout rather than the older
       callback forms these calls also take. Safari read an offer, built
       the connection, and took the candidates, but the callback handed
       to setRemoteDescription was never run and no error was given, so
       the answer was never made and the caller waited on nothing. The
       offer side already used the promise form and connected; the
       answer side now matches it. */
    peer_connection.setRemoteDescription(
        new RTCSessionDescription(data)).then(function () {
        if (answer) {
            return;
        }
        answer = true;
        setDisplay('av-call-div', false);
        return peer_connection.createAnswer().then(function (desc) {
            return peer_connection.setLocalDescription(desc);
        }).then(function () {
            console.log("publishing answer method");
            publish('client-answer', peer_connection.localDescription);
        });
    }).catch(function (event) {
        console.log("Problem while doing client-offer: ", event);
    });
}
/**
 * Status-update handler: appends the server-rendered conversation
 * HTML in $data to the local conversation pane and flashes the
 * background to indicate new content.
 *
 * @param {string} type the event-source envelope type tag
 * @param {string} data server-rendered HTML for the new
 *      conversation rows (with a data-time attribute on the
 *      outer .conversation element)
 */
function handleStatus(type, data)
{
    if (!data) {
        return;
    }
    console.log("handleStatus method");
    let conversation_obj = elt('conversation');
    if (!conversation_obj) {
        return;
    }
    conversation_obj.style.backgroundColor = "#EEE";
    let tmp_container = document.createElement("div");
    tmp_container.innerHTML = data;
    let new_conversation_obj = tmp_container.getElementsByClassName(
        'conversation')[0];
    if (!new_conversation_obj) {
        return;
    }
    let update_time =
        new_conversation_obj.getAttribute('data-time');
    if (update_time) {
        conversation_obj.setAttribute('data-time', update_time);
    }
    /*
       The conversation lays its messages out bottom-to-top with a
       column-reverse flex direction, so the newest message is the first
       child, nearest the input box. New messages therefore go in at the
       front, not the end; appending them would place them at the top of
       the view, away from where the person is reading and typing.
     */
    /* Only messages the conversation does not already hold go in. The
       server answers with the whole conversation, so putting all of it
       in front of what was there repeated every message on every update,
       which is how one recording became ten. */
    let already = {};
    for (let held of conversation_obj.children) {
        let mark = held.getAttribute("data-message-id");
        if (mark) {
            already[mark] = true;
        }
    }
    let fresh = "";
    for (let coming of new_conversation_obj.children) {
        let mark = coming.getAttribute("data-message-id");
        if (mark && already[mark]) {
            continue;
        }
        fresh += coming.outerHTML;
    }
    conversation_obj.innerHTML = fresh + conversation_obj.innerHTML;
    addTranscriptToggles(conversation_obj);
    setTimeout("resetBackground()", 0.5 * sec);
}
/**
 * EventSource.send shim: POSTs the serialized message back to
 * the server's call-event endpoint via the post() helper.
 *
 * @param {object} message envelope produced by publish()
 */
function sendMessage(message)
{
    console.log("sendMessage method");
    console.log("Sending via fetch api: ", message);
    if (message && message.type != 'status') {
        callTrace("fetch", "sent " + message.type);
    }
    post(start_url + "&type=call-event",
        message,
        (data) => {
            // Success function.
            console.log("Successfully sent message:", message);
            console.log("Data back from server:", data);
        }
    );
}
/**
 * Lazily constructs the RTCPeerConnection, attaches ICE
 * candidate / track / failure handlers, and wires up the local
 * media stream. Idempotent: subsequent calls become no-ops via
 * the have_set_local_ice_candidate guard.
 *
 * @param {MediaStream} local_stream local audio+video stream to
 *      add to the peer connection
 */
function icecandidate(local_stream)
{
    if (have_set_local_ice_candidate) {
        console.log("Already set ice candidate for local stream. Returning.");
        return;
    }
    console.log("icecandidate method");
    console.log(configuration);
    peer_connection = new RTCPeerConnection(configuration);
    peer_connection.onicecandidate = function (event) {
        console.log("Got ice candidate");
        console.log(event);
        /* The last thing a browser reports is an end of candidates, an
           object whose candidate line is empty. It is not a place the
           other side can be reached at, and sending it makes that side
           report being given no candidate, so only a real one is sent. */
        if (event.candidate && event.candidate.candidate &&
            call_state && call_state != 'video-end' &&
            call_state != 'video-end-received' &&
            event.candidate != sent_candidate) {
            console.log("sending");
            /* The candidate's type says how a browser would be reached:
               host is its own address, srflx is what a STUN server saw,
               and relay is one the TURN server would carry. A call
               between two home networks usually needs a relay candidate
               on at least one side, so its presence in the trace says
               the TURN server answered. */
            callTrace("-", "local-candidate", event.candidate.type);
            sent_candidate = event.candidate;
            publish('client-candidate', event.candidate);
        }
    };
    try {
        peer_connection.addStream(local_stream);
    } catch(event) {
        for(const track of local_stream.getTracks()) {
          peer_connection.addTrack(track, local_stream);
        }
    }
    peer_connection.ontrack = function (event) {
        console.log("Trying to add stream to remote_av");
        callTrace("-", "remote-track-arrived");
        remote_stream = event.streams[0];
        remote_av.srcObject = remote_stream;
        setDisplay('av-call-div', false);
        cancelCallSound();
    };
    peer_connection.oniceconnectionstatechange = function () {
        callTrace("-", "ice-state", watched.iceConnectionState);
    };
    let watched = peer_connection;
    peer_connection.onconnectionstatechange = function () {
        callTrace("-", "connection-state", watched.connectionState +
            (watched === peer_connection ? "" : " of a call that has " +
            "gone"));
        if (watched !== peer_connection) {
            return;
        }
        if (watched.connectionState == 'failed') {
            sayCallingIsDown();
            setCallState('video-end');
        }
    };
    have_set_local_ice_candidate = true;
}
var loading_messages = false;
var has_more_messages = true;
var last_scroll_time = 0;
/*
 * Handles scroll events on the conversation div to implement infinite scroll
 */
function handleConversationScroll()
{
    var conversation = elt('conversation');
    if (!conversation || loading_messages || !has_more_messages) {
        return;
    }
    var now = Date.now();
    if (now - last_scroll_time < 500) {
        return;
    }
    last_scroll_time = now;
    if (conversation.scrollTop < 100) {
        loadMoreMessages();
    }
}
/*
 * Loads more messages from the server for infinite scroll
 */
function loadMoreMessages()
{
    if (loading_messages) {
        return;
    }
    loading_messages = true;
    var conversation = elt('conversation');
    var contact_id = conversation.getAttribute('data-contact-id');
    var loaded_count = parseInt(conversation.getAttribute('data-loaded-count'));
    var oldest_timestamp = parseInt(conversation.getAttribute(
        'data-oldest-timestamp'));
    if (!contact_id || !oldest_timestamp) {
        loading_messages = false;
        return;
    }
    var controller = 'social';
    var params = new URLSearchParams({
        'c': controller, 'a': 'userMessages', 'arg': 'loadmessages',
        'contact_id': contact_id, 'before_timestamp': oldest_timestamp,
        'limit': 20});
    var csrf_input = document.querySelector('input[name="' +
        window.CSRF_TOKEN + '"]');
    if (csrf_input) {
        params.append(window.CSRF_TOKEN, csrf_input.value);
    }
    var base_url = window.start_url.replace('&arg=status',
        '&arg=loadmessages');
    base_url += '&contact_id=' + contact_id;
    base_url += '&before_timestamp=' + oldest_timestamp;
    base_url += '&limit=10';
    fetch(base_url, {
        method: 'GET',
        headers: {
            'Accept': 'application/json',
        }
    })
    .then(response => {
        const content_type = response.headers.get('content-type');
        if (!content_type ||
            !content_type.includes('application/json')) {
            return response.text().then(text => {
                throw new Error('Server returned HTML instead of JSON');
            });
        }
        return response.json();
    })
    .then(data => {
        if (data.error) {
            loading_messages = false;
            return;
        }
        if (data.html && data.message_count > 0) {
            conversation.insertAdjacentHTML('beforeend', data.html);
            has_more_messages = data.has_more;
            var new_loaded_count = loaded_count + data.message_count;
            conversation.setAttribute('data-loaded-count', new_loaded_count);
            if (data.oldest_timestamp) {
                conversation.setAttribute('data-oldest-timestamp',
                    data.oldest_timestamp);
            }
        } else {
            has_more_messages = false;
        }
        loadingMessages = false;
    })
    .catch(error => {
        loadingMessages = false;
    });
}

/**
 * watchContactField offers the names a person may write to as they type
 * into the contact box, and acts on the one they choose. Choosing
 * somebody already written to opens their conversation, since their
 * number alone is what the messages screen reads to open one. Choosing
 * anybody else asks them to become a contact and opens the conversation
 * that request belongs to. The offering itself is done for every screen
 * that offers names, so only what a chosen contact means lives here.
 */
function watchContactField()
{
    offerNamesWhileTyping('contact-filter', 'contact-suggestions',
        window.suggest_url, window.suggest_least || 3,
        window.suggest_wait || 2000,
        {known: window.suggest_known_says,
        other: window.suggest_new_says},
        function (picked, field)
        {
            let form = field.form;
            if (!picked.known) {
                form.appendChild(hiddenField('arg', 'addcontact'));
            }
            form.appendChild(hiddenField('contact_id', picked.id));
            form.submit();
        });
}
listen(window, "load", watchContactField);
