/**
* 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;
});
}