SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ 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;
 /**
  * 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()
{
     if (message_socket) {
         message_socket.close();
     }
     if (event_source) {
         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;
    }
    /*
       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() {
                openMessageEventSource();
            };
        } catch (e) {
            openMessageEventSource();
        }
    } else {
        openMessageEventSource();
    }
    setCallState(null, '');
    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()
{
    try {
        event_source = new EventSource(start_url);
        event_source.onmessage = handleMessage;
    } catch (e) {
        console.error("Could not create eventsource.", e);
    }
}
/**
 * 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') {
            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.
     */
    conversation_obj.innerHTML = new_conversation_obj.innerHTML +
        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;
    });
}