/ 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 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;
 /**
  * Closes the message-stream EventSource and replaces the
  * conversation pane with a "no longer updating" notice; triggered
  * by the 20-minute idle timeout in doUpdate.
  */
function clearUpdate()
{
     event_source.close();
     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;
    }
    try {
        event_source = new EventSource(start_url);
        event_source.send = sendMessage;
        event_source.onmessage = handleMessage;
    } catch (e) {
        console.error("Could not create eventsource.", e);
    }
    setCallState(null, '');
    setTimeout("clearUpdate()", 20 * minute + sec);
}
/**
 * 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);
        }
        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') {
            publish('call-end', null);
        }
        elt('av-call').innerHTML = '';
        setDisplay('video-start', true);
        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()
{
    elt('phone-sound').loop = true;
    elt('phone-sound').play();
    calling_timer_id = setInterval(() => {
        let call_toggle = elt('conversation-header');
        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');
    call_toggle.style.backgroundColor = call_toggle_color;
    elt('phone-sound').fastSeek(0);
    elt('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)
{
    console.log("publish sending: " , event);
    event_source.send({
        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);
    setDisplay('video-end', true);
}
/**
 * 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');
    let conversation_time = (conversation_obj) ?
    parseInt(conversation_obj.getAttribute('data-time')) : 0;
    conversation_obj.style.backgroundColor = "#EEE";
    let tmp_container = document.createElement("div");
    tmp_container.innerHTML = data;
    let new_conversation_obj = tmp_container.getElementsByClassName(
        'conversation')[0];
    let update_time =
        new_conversation_obj.getAttribute('data-time');
    if (update_time) {
        conversation_obj.setAttribute('data-time', update_time);
    }
    conversation_obj.innerHTML = conversation_obj.innerHTML +
        new_conversation_obj.innerHTML;
    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