/ src / scripts / messages.js
/**
 * 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;
/**
 * 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.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;
                openMessageEventSource();
            };
        } catch (problem) {
            openMessageEventSource();
        }
    } else {
        openMessageEventSource();
    }
    setCallState(null, '');
    /* 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. */
    window.addEventListener("pagehide", 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;
    } catch (problem) {
        console.error("Could not create eventsource.", problem);
    }
}
/**
 * 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)
{
    old_call_state = call_state;
    call_state = state;
    console.log("state:" + call_state + "; old_state:" + old_call_state);
    if (!event_source) {
        console.log("setCallState: no event source bailing!");
    }
    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());
        }
        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()
{
    var 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;
    }
    var phone_sound = elt('phone-sound');
    if (phone_sound) {
        phone_sound.fastSeek(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 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");
    peer_connection.setRemoteDescription(
        new RTCSessionDescription(data),function() {},
        function(e) {
            console.log("Problem while doing client-answer: ", e);
            return;
    });
}
/**
 * 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-answer, '+
            'I need a client-offer');
        return;
    }
    console.log("handleClientCandidate method");
    peer_connection.addIceCandidate(new RTCIceCandidate(data), function(){},
        function(e) {
            console.log("Problem adding ice candidate: " + e);
            return;
        }
    );
}
/**
 * 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');
}
/**
 * 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()
{
    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);
    peer_connection.setRemoteDescription(
        new RTCSessionDescription(data),
        function() {
            if (!answer) {
                peer_connection.createAnswer(function (desc) {
                    peer_connection.setLocalDescription(desc, function () {
                        console.log("publishing answer method");
                        publish('client-answer',
                            peer_connection.localDescription);
                    }, function(e) {
                        console.log("Problem getting client answer: ",e);
                        return;
                    });
                },
                function(e) {
                    console.log("Problem while doing client-offer: ", e);
                    return;
                });
                setDisplay('av-call-div', false);
                answer = true;
            }
        },
        function(e) {
            console.log("Problem while doing client-offer2: ", e);
            return;
        }
    );
}
/**
 * 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);
    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);
        if (event.candidate && event.candidate != sent_candidate) {
            console.log("sending");
            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");
        remote_stream = event.streams[0];
        remote_av.srcObject = remote_stream;
        setDisplay('av-call-div', false);
        cancelCallSound();
    };
    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;
    });
}
X