/ src / controllers / components / MessageComponent.php
<?php
/**
 * 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
 */
namespace seekquarry\yioop\controllers\components;

use seekquarry\yioop as B;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\mail as ML;
use seekquarry\yioop\library\av_processing\VideoExtractor;
use seekquarry\yioop\models\MailAccountModel;
use seekquarry\yioop\models\SigninModel;
use seekquarry\yioop\library\CrawlConstants;
use seekquarry\yioop\library\mail\MailScheduledDispatcher;
use seekquarry\yioop\library\mail\MailHeaderParser;
use seekquarry\yioop\library\mail\MailSiteFactory;
use seekquarry\yioop\library\mail\SmtpClient;
use seekquarry\yioop\library\UrlParser;
use seekquarry\yioop\library\wiki\WikiParser;
use seekquarry\yioop\library\FetchUrl;
use seekquarry\yioop\library\language_processing\PhraseParser;
use seekquarry\yioop\library\processors\ImageProcessor;
use seekquarry\yioop\library\mail\ImapEnvelopeParser;
use seekquarry\yioop\library\mail\ImapListing;
use seekquarry\yioop\library\mail\ImapFolderListParser;
use seekquarry\yioop\library\mail\ImapResponseParser;
use seekquarry\yioop\library\mail\MimeMessage;
use seekquarry\yioop\library\mail\MailComposeBuilder;
use seekquarry\yioop\views\elements\MailElement;
use seekquarry\yioop\library\media_jobs as LMJ;
use seekquarry\yioop\library\version_control as LVC;
use seekquarry\yioop\library\wiki as LW;
/**
 * MessageComponent draws the screens where one user writes to another
 * and saves what they write. A message goes to a single reader rather
 * than to a group, and the messages between two people make a
 * conversation.
 *
 * It draws the list of conversations and the messages within one, and
 * the form for writing a message. It fetches messages that arrived
 * while a reader was looking at the screen. It holds the contacts a
 * user writes to: adding one, ignoring one, blocking one, and taking
 * one away. It answers a call a contact has started.
 *
 * This component extends SocialComponent, the base every component
 * about a group extends. The base holds what more than one of them
 * needs, so no component calls another.
 *
 * AdminController and GroupController offer its userMessages activity.
 *
 * @author Chris Pollett
 */
class MessageComponent extends SocialComponent
{
    /**
     * namedCallServers reads the servers a site owner wrote on the
     * Servers screen into the shape a browser wants. A server is
     * written on a line of its own as it would be dialed, and one that
     * wants a name and a secret carries them after the address with
     * spaces between.
     *
     * callRelays calls this where the owner picked external or both,
     * and hands what it gives to the browser beside the site's own
     * relay. The servers are read from the profile, which is the file
     * the Servers screen writes, so a server that has stayed up since
     * before they were saved still reads them.
     *
     * @return array each entry a piece of JavaScript naming a server
     */
    public function namedCallServers()
    {
        $said = [];
        $profile = $this->parent->model("profile")->getProfile(
            C\WORK_DIRECTORY);
        foreach (preg_split("/[\n,]+/",
            (string)($profile['CALL_SERVERS'] ?? '')) as $line) {
            $parts = preg_split("/\s+/", trim($line));
            $where = $this->parent->clean($parts[0] ?? "", "string");
            if ($where === "") {
                continue;
            }
            $entry = "{'urls': '" . $where . "'";
            if (!empty($parts[1])) {
                $entry .= ", 'username': '" . $this->parent->clean(
                    $parts[1], "string") . "'";
            }
            if (!empty($parts[2])) {
                $entry .= ", 'credential': '" . $this->parent->clean(
                    $parts[2], "string") . "'";
            }
            $said[] = $entry . "}";
        }
        return $said;
    }
    /**
     * callRelayToken works out the two words a browser shows the relay
     * this site runs so that the relay carries its call: a name, and a
     * secret to go with that name.
     *
     * A pair is made up for each browser rather than a lasting password
     * being handed out. The name says the moment it stops being good,
     * and the secret is that name hashed with the key the relay hashes
     * it with too: the setting where one is written there, and the site
     * key where it is left empty. Such a pair lasts a day, so one read
     * off a page is no use to anyone later, and a relay set up this way
     * is told the key rather than a password of its own.
     *
     * callRelays calls this for the entry it hands a browser for the
     * site's own relay.
     *
     * @return array the name under the key name and the secret under
     *      the key secret, for a browser to show the relay
     */
    public function callRelayToken()
    {
        $name = (time() + C\ONE_DAY) . ":" .
            $this->parent->clean(C\p('TURN_USER'), "string");
        $key = (C\p('TURN_SECRET') !== "") ? C\p('TURN_SECRET') :
            C\p('AUTH_KEY');
        return ["name" => $name, "secret" => base64_encode(hash_hmac(
            'sha1', $name, $key, true))];
    }
    /**
     * callServiceMode gives the word a site owner picked on the Servers
     * screen for which relays a call may use: off, turnsite for the
     * relay this site runs, external for the servers written on that
     * screen, or both. A site that never saved that screen picked
     * nothing, and off is what that means, so an empty word is read as
     * off here rather than at each place that asks.
     *
     * The word is read from the profile, which is the file the Servers
     * screen writes when a site owner presses Save. The named settings
     * are not read instead, since those hold what that file said when
     * the server started and a server may stay up for weeks.
     *
     * callRelays takes this word and hands a browser the relays that
     * go with it.
     *
     * @return string the choice a site owner made, one of off,
     *      turnsite, external or both
     */
    public function callServiceMode()
    {
        $profile = $this->parent->model("profile")->getProfile(
            C\WORK_DIRECTORY);
        $mode = trim((string)($profile['CALL_SERVICE_MODE'] ?? ''));
        return ($mode === '') ? 'off' : $mode;
    }
    /**
     * callRelays gives the list of relays a browser should try when it
     * cannot reach the other browser directly. Each entry is written
     * as JavaScript, since that is what the page hands
     * RTCPeerConnection.
     *
     * A site owner may write entries by hand in GROUP_CALL_ICE_SERVERS,
     * naming a relay run elsewhere. Where Yioop is running a relay of
     * its own, an entry for it is added from the settings it was
     * started with, so turning the relay on is all that is needed.
     *
     * @param mixed $set_by_hand what GROUP_CALL_ICE_SERVERS holds,
     *      which is null where a site owner has written none
     * @param bool $own_relay_running whether this site's own relay is
     *      running
     * @param string $mode which relays the site owner chose on the
     *      Servers screen: off hands over none, turnsite the site's
     *      own, external the ones written on that screen, and both
     *      hands over each kind. callServiceMode gives what a running
     *      site picked; a caller names the word itself to ask what
     *      another choice would hand over.
     * @return array each entry a piece of JavaScript naming one relay
     */
    public function callRelays($set_by_hand, $own_relay_running, $mode)
    {
        if ($mode === 'off') {
            return [];
        }
        $relays = [];
        if ($mode === 'external' || $mode === 'both') {
            foreach ($this->namedCallServers() as $one) {
                $relays[] = $one;
            }
        }
        if (!empty($set_by_hand)) {
            foreach ($set_by_hand as $one) {
                $relays[] = $one;
            }
        }
        if (($mode !== 'turnsite' && $mode !== 'both') ||
            empty(C\p('TURN_PORT'))) {
            return $relays;
        }
        if (!$own_relay_running) {
            return $relays;
        }
        /* The address a browser is given is the one it can reach the
           relay at, which is the site's own host rather than whatever
           the relay was told to bind: a relay bound to every address
           reports itself as 0.0.0.0, and one bound to the loopback is
           reachable only from the machine it runs on. */
        $host = parse_url(C\baseUrl(), PHP_URL_HOST);
        if (empty($host)) {
            $host = parse_url(C\p('NAME_SERVER'), PHP_URL_HOST);
        }
        if (empty($host) || $host == "0.0.0.0" || $host == "::") {
            /* The address the relay listens on is not one a browser can
               dial: 0.0.0.0 means every address of this machine. Handing
               it over gave browsers a relay they could not reach, so the
               site's own is left out. Any relay written on the Servers
               screen still stands, since it was reachable either way. */
            return $relays;
        }
        $port = intval(C\p('TURN_PORT'));
        $named = $this->callRelayToken();
        /* The relay answers both questions a browser asks: where it is
           seen from, which is what stun means, and carrying what it
           sends, which is what turn means. */
        $relays[] = "{'urls': 'stun:" . $host . ":" . $port . "'}";
        $relays[] = "{'urls': 'turn:" . $host . ":" . $port .
            "', 'username': '" . $named["name"] .
            "', 'credential': '" . $named["secret"] . "'}";
        return $relays;
    }
    /**
     * nextCallEventEnvelope reads the next call-signaling event waiting
     * for a user in the conversation with a contact and returns it
     * wrapped as an envelope the browser's message handler understands,
     * so a call's offers, answers and candidates can arrive over the
     * WebSocket push rather than only over the event stream.
     *
     * The two people in a conversation share one call, named by hashing
     * their two ids in a fixed order, so each side derives the same
     * name. Only events another person sent, newer than the time the
     * caller passes, are returned; the caller passes the time of the
     * last event it sent on so each event arrives once. When no call is
     * running, or nothing newer is waiting, the return carries no
     * envelope and the caller sends nothing.
     *
     * @param int $user_id id of the signed-in user whose channel this is
     * @param int $contact_id id of the other party in the conversation
     * @param string|float $since_micro_time only events after this
     *      microtime are returned; pass the time of the last event sent
     *      on
     * @return array with time set to the newest event time seen, or the
     *      given time when nothing is new, and, when an event waits,
     *      envelope holding the JSON frame to send
     */
    public function nextCallEventEnvelope($user_id, $contact_id,
        $since_micro_time)
    {
        $group_model = $this->parent->model("group");
        $call_hash_id = $this->callHashId($user_id, $contact_id);
        $event_info = $group_model->nextCallEvent($call_hash_id,
            $user_id, $since_micro_time);
        if (!$event_info) {
            return ['time' => $since_micro_time];
        }
        /* A call that is over still has its ending to hand over. Turning
           a call down takes the call away and leaves that ending behind,
           so refusing to look at events once the call has gone left the
           caller ringing at nobody. Anything else from a call that is
           over is passed by. */
        if (!$group_model->isActiveCall($call_hash_id) &&
            ($event_info['EVENT']['type'] ?? "") != 'call-end') {
            return ['time' => $event_info['EVENT_TIME']];
        }
        return ['time' => $event_info['EVENT_TIME'],
            'envelope' => json_encode($event_info['EVENT'])];
    }
    /**
     * newMessageStatusPayload builds the push payload that tells a
     * conversation's open channel about messages newer than a given time. Both
     * the server-sent event refresh and the WebSocket push send the same thing:
     * the conversation fragment wrapped as a status envelope the browser's
     * message handler already understands. The work of finding the new messages
     * and rendering them therefore lives here once rather than in each channel.
     * When nothing is newer than the given time the payload carries no
     * envelope, so a caller can skip sending.
     * @param int $user_id id of the signed-in user whose conversation channel
     *     this is
     * @param int $contact_id id of the other party in the conversation
     * @param int $since_time only messages posted after this Unix time are
     *     included; pass the time of the last push so each payload carries only
     *     what is new
     * @return array with 'time' set to the newest message time seen (or the
     *     given time when nothing is new) and, when there are new messages,
     *     'envelope' holding the JSON status frame to send
     */
    public function newMessageStatusPayload($user_id, $contact_id,
        $since_time)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $user_model = $parent->model("user");
        $data = [];
        $data['STATUS'] = true;
        $data['CONTACT_ID'] = $contact_id;
        $data['MESSAGES'] = [];
        /*
           The message element decides a message's side and color by
           comparing each message's author to the signed-in user's name,
           and it reads the locale text direction and the mobile flag while
           laying a message out. A normal page render gets these from the
           controller; this push builds its own data, so the same keys are
           set here, or every pushed message would warn on the missing name
           and render as if it came from the other party.
         */
        $data['USERNAME'] = $user_model->getUsername($user_id);
        $data['LOCALE_DIR'] = L\getLocaleDirection();
        $data['MOBILE'] = false;
        $data['USER_MESSAGES_ID'] =
            $group_model->getPersonalGroupId($user_id);
        $feed_model = $parent->model("feed");
        $message_thread_id = $feed_model->getGroupThreadId(
            $data['USER_MESSAGES_ID'], $user_id,
            $feed_model->getMessagesThreadTitle(
            [$user_id, $contact_id]));
        if (!$message_thread_id) {
            return ['time' => $since_time];
        }
        $search_array = [
            ["parent_id", "=", $message_thread_id, ""],
            ["group_id", "=", $data['USER_MESSAGES_ID'], ""],
            ["pubdate", ">", $since_time, "DESC"]];
        $limit = 0;
        list(, $data['MESSAGES']) = $this->initializeFeedItems($data,
            [], $user_id, $search_array, -2, "krsort", $limit, 100);
        if (empty($data['MESSAGES'])) {
            return ['time' => $since_time];
        }
        $newest_time = $since_time;
        foreach ($data['MESSAGES'] as $message) {
            if (!empty($message['PUBDATE']) &&
                $message['PUBDATE'] > $newest_time) {
                $newest_time = $message['PUBDATE'];
            }
        }
        ob_start();
        $parent->view("api")->element("usermessages")->render($data);
        $fragment = ob_get_clean();
        $envelope = json_encode(['type' => 'status', 'data' => $fragment]);
        return ['time' => $newest_time, 'envelope' => $envelope];
    }
    /**
     * userMessages handles requests for the user messages subsystem of yioop
     * that allows users to directly send messages to each other. This involves
     * for a user request gettting a list of user contacts, getttingmessages of
     * the currently selected user and handles any new messages posted. This
     * data is the sent to UsermessagesElement for display.
     * @return mixed associative $data array prepared for UsermessagesElement,
     *     or the result of redirectWithMessage if the user has no permission
     *     and the response was a redirect
     */
    public function userMessages()
    {
        $parent = $this->parent;
        $controller_name = (get_class($parent) == C\NS_CONTROLLERS .
            "AdminController") ? "admin" : "group";
        $data["CONTROLLER"] = $controller_name;
        $data['VIEW_MODE'] = "ungrouped";
        $data['SUBTITLE'] = C\PERSONAL_GROUP_PREFIX;
        $group_model = $parent->model("group");
        $feed_model = $parent->model("feed");
        $user_model = $parent->model("user");
        $data["ELEMENT"] = "usermessages";
        $data['SCRIPT'] = "";
        $data["INCLUDE_STYLES"] = ["messages", "editor"];
        $data["INCLUDE_SCRIPTS"] = ["messages"];
        if (!isset($_SESSION['USER_ID'])) {
            $_REQUEST = ['c' => "admin", 'a' => '', C\p('CSRF_TOKEN') => ''];
            return $parent->redirectWithMessage(
                tl("social_component_login_first"));
        }
        $user_id = $_SESSION['USER_ID'];
        $username = $user_model->getUsername($user_id);
        if (isset($_REQUEST['num'])) {
            $results_per_page = $parent->clean($_REQUEST['num'], "int");
        } else if (isset($_SESSION['MAX_PAGES_TO_SHOW']) &&
            $_SESSION['MAX_PAGES_TO_SHOW'] > 0) {
            $results_per_page = $_SESSION['MAX_PAGES_TO_SHOW'];
        } else {
            $results_per_page = C\NUM_RESULTS_PER_PAGE;
        }
        if (isset($_REQUEST['limit'])) {
            $limit = $parent->clean($_REQUEST['limit'], "int");
        } else {
            $limit = 0;
        }
        $contact_id = $parent->clean($_REQUEST["contact_id"]
            ?? "", 'int');
        $contact_filter = $parent->clean($_REQUEST['contact_name'] ?? "",
            "string");
        $data['USER_ID'] = $user_id;
        $data['CONTACT_ID'] = $contact_id;
        $data['CONTACT_NAME'] = (!empty($contact_id)) ?
            $user_model->getUsername($contact_id) : "";
        $data['CONTACT_FILTER'] = $contact_filter;
        $data['CONTACT_ICON_URL'] = $user_model->getUserIconUrl($contact_id);
        $user_messages_id = $group_model->getPersonalGroupId($user_id);
        $data['USER_MESSAGES_ID'] = $user_messages_id;
        $message_actions = ['addcontact' => 'addContact',
            'blockcontact' => 'blockContact',
            'ignorecontact' => 'ignoreContact',
            'newmessage' => 'newMessage',
            'loadmessages' => 'loadMoreMessages',
            'deleteconversation' => 'deleteConversation',
            'removecontact' => 'removeContact',
            'deletemessage' => 'deleteMessage',
            "ringing" => "ringingContacts",
            "status" => "messagesStatus"];
        $arg = $_REQUEST['arg'] ?? "";
        /* The two buttons beside a ringing contact's name. Picking up
           opens that contact's conversation, where the call machinery
           already on that screen joins the call. Turning it down
           removes the call, so the caller's side sees it end and the
           name stops pulsing for everyone. */
        $decline_id = $parent->clean($_REQUEST['decline_call'] ?? "", 'int');
        if (!empty($decline_id)) {
            $declined_hash_id = $this->callHashId($user_id, $decline_id);
            $group_model->addCallEvent($declined_hash_id, $user_id,
                'call-end', null);
            $group_model->deleteGroupCall($declined_hash_id);
            /* Turning a call down opens the conversation with the
               person who rang rather than leaving the screen with no
               conversation at all, so the reader can see what they have
               said and write back. */
            $_REQUEST['contact_id'] = $decline_id;
            $data['CONTACT_ID'] = $decline_id;
            $data['CONTACT_NAME'] = $user_model->getUsername($decline_id);
        }
        $answer_id = $parent->clean($_REQUEST['answer_call'] ?? "", 'int');
        if (!empty($answer_id)) {
            $_REQUEST['contact_id'] = $answer_id;
            $data['CONTACT_ID'] = $answer_id;
            $data['CONTACT_NAME'] = $user_model->getUsername($answer_id);
            /* The screen only takes the call where one is still waiting.
               A reload repeats the address that answered, and without
               this the screen would place a fresh call every time the
               reader loaded the page again. */
            $answer_hash_id = $this->callHashId($user_id, $answer_id);
            $data['ANSWER_CALL'] = !empty($group_model->callsRingingFor(
                [$answer_id => $answer_hash_id], $user_id));
        }
        if (in_array($arg, array_keys($message_actions))) {
            $action = $message_actions[$arg];
            if (!empty($contact_filter)) {
                $data['CONTACT_ID'] = $user_model->getUserId($contact_filter);
                $data['CONTACT_NAME'] = $contact_filter;
                $data['CONTACT_FILTER'] = "";
            }
            return $this->$action($user_id, $data);
        }
        $data[C\p('CSRF_TOKEN')] =
            $parent->generateCSRFToken($user_id);
        $running = L\CrawlDaemon::statuses();
        $relays = $this->callRelays(C\GROUP_CALL_ICE_SERVERS,
            isset($running['TurnServer']), $this->callServiceMode());
        /* The call controls are shown when calling is turned on, which
           is any choice other than off. The old test, whether the
           GROUP_CALL_ICE_SERVERS constant was set by hand, hid the
           controls on a site that turned calling on through the Servers
           screen and let its own relay supply the servers. */
        $data['CALL_ENABLED'] = ($this->callServiceMode() !== 'off');
        /* Whether any relay was found for this site. A call between two
           home networks needs one, so where none answers the screen says
           so on the call control rather than letting a call be placed
           that cannot connect. */
        $data['CALL_RELAY_FOUND'] = ($relays !== []);
        if ($relays !== []) {
            $data['SCRIPT'] .= "configuration = {'iceServers': [" .
                implode(",", $relays) . "]};";
        }
        /* The call trace writes each signaling step to the browser
           console so a call placed between two browsers can be read to
           see where it stops. It is turned on for the page when the
           site's debug level asks for developer messages. */
        if ((intval(C\p('DEBUG_LEVEL')) & C\ERROR_INFO) == C\ERROR_INFO) {
            $data['SCRIPT'] .= "window.call_trace = true;";
        }
        $messages_url = B\feedsUrl('user_messages',
            $data['CONTACT_ID'], true, $data['CONTROLLER']) .
            C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')];
        $data['SCRIPT'] .= 'window.start_url = "'. $messages_url .
            '&arg=status";';
        $data['SCRIPT'] .= 'window.call_contact_id = "' .
            ($data['CONTACT_ID'] ?? "") . '";';
        if (!empty($data['ANSWER_CALL'])) {
            $data['SCRIPT'] .= 'window.answer_waiting_call = true;';
        }
        if (!empty($data['CALL_ENABLED'])) {
            $data['SCRIPT'] .= 'window.ringing_watch_url = "' .
                $messages_url . '&arg=ringing";';
        }
        /*
           When Yioop is serving itself through the command-line WebSite,
           which can hold a socket open, and a conversation is actually
           selected, hand the browser a WebSocket address for that
           conversation so it receives new messages the moment they land.
           IS_OWN_WEB_SERVER is the constant that server defines to mark
           that it is the one serving; under Apache or nginx-FPM it is
           absent and the browser keeps to the event-stream path. With no
           conversation selected there is nothing to watch, so the address
           is left unset. The scheme follows the page's and the contact is
           named so the socket knows which conversation to watch.
         */
        if (!empty($data['CONTACT_ID']) &&
            C\nsdefined('IS_OWN_WEB_SERVER') && C\IS_OWN_WEB_SERVER) {
            $ws_scheme = (!empty($_SERVER['HTTPS'])) ? "wss" : "ws";
            $ws_host = $_SERVER['HTTP_HOST'] ?? "";
            $data['SCRIPT'] .= 'window.ws_messages_url = "' . $ws_scheme .
                '://' . $ws_host . '/ws-messages?contact_id=' .
                urlencode($data['CONTACT_ID']) . '";';
        }
        $data['SCRIPT'] .=  "window.tl = {".
            'social_component_no_longer_update:"'.
                tl('social_component_no_longer_update').'"'.
            '}; window.USERNAME = "' . $user_model->getUsername($user_id) .
                '";'.
            'window.CSRF_TOKEN = "' . C\p('CSRF_TOKEN') . '";'.
            'window.onload = doUpdate;';
        $contact_ids = array_diff($group_model->getGroupUserIds(
            $user_messages_id), [$user_id]);
        $contacts = $this->marshallContactInfo($user_id, $contact_ids,
            $contact_filter);
        $requests_thread_id = $feed_model->getGroupThreadId(
            $user_messages_id, null,  $feed_model->getMessagesThreadTitle(
                [$user_id]));
        $contact_requests = [];
        if (!empty($requests_thread_id)) {
            $pre_contact_requests = $feed_model->getThreadFollowers(
                $requests_thread_id);
            $contact_requests = [];
            foreach ($pre_contact_requests as $pre_contact_request) {
                if (!empty($contact_name) && strpos(
                    $pre_contact_request['USER_NAME'],
                    $contact_filter) === false) {
                    continue;
                }
                $pre_contact_request_id = $pre_contact_request['USER_ID'];
                $pre_contact_request['ICON_URL'] =
                    $user_model->getUserIconUrl($pre_contact_request_id);
                $contact_requests[$pre_contact_request_id] =
                    $pre_contact_request;
            }
        }
        $data['CONTACTS'] = $contacts;
        $data['CONTACT_REQUESTS'] = $contact_requests;
        $data['ELEMENT'] = 'usermessages';
        $data['MESSAGES'] = [];
        if (!empty($contact_id)) {
            $message_thread_id = $feed_model->getGroupThreadId(
                $user_messages_id, $user_id,
                    $feed_model->getMessagesThreadTitle(
                    [$user_id, $contact_id]));
            if ($message_thread_id) {
                $parent->model("impression")->add($user_id, $message_thread_id,
                    C\THREAD_IMPRESSION);
                $search_array = [
                 ["parent_id", "=", $message_thread_id, ""],
                 ["group_id", "=", $user_messages_id, ""],
                 ["pubdate", "", "", "DESC"]];
                list(, $data['MESSAGES']) = $this->initializeFeedItems($data,
                    [], $user_id, $search_array, -2, "krsort", $limit,
                    15);
            }
        }
        $this->initSocialBadges($user_id, $data);
        $this->initializeWikiEditor($data, -1);
        return $data;
    }
    /**
     * marshallContactInfo gets an array of contact details including USER_NAME,
     * ICON_URL, NUM_UNREAD_MESSAGES for an array of contact ids for $user_id,
     * subject to a filter.
     * @param int $user_id the viewer whose unread-message counts are computed
     *     against the contact list
     * @param array $contact_ids id's that contact details are desired of
     * @param string $contact_filter a substring of user_names to restrict the
     *     returned details to (i.e., only contacts whose user name's match this
     *     substring)
     * @return array $contacts array of contact details (which in turn is an
     *     array with fields USER_NAME, ICON_URL, ...) for ids with user names
     *     matching the filter
     */
    private function marshallContactInfo($user_id, $contact_ids,
        $contact_filter = "")
    {
        $parent = $this->parent;
        $user_model = $parent->model("user");
        $group_model = $parent->model("group");
        $feed_model = $parent->model("feed");
        $impression_model = $parent->model("impression");
        $contacts= [];
        $personal_group_id = $group_model->getPersonalGroupId($user_id);
        /* Who is signed in and whose call is waiting are asked once for
           the whole list rather than per contact, since either one asked
           per row would be a query for each name in the list. Both are
           only asked where calling is turned on, as a site with calling
           off shows neither mark. */
        $presence = [];
        $ringing = [];
        $call_hash_ids = [];
        $call_enabled = ($this->callServiceMode() !== 'off');
        if ($call_enabled) {
            $messages_groups = [];
            foreach ($contact_ids as $contact_id) {
                $messages_groups[$contact_id] =
                    $group_model->getPersonalGroupId($contact_id);
            }
            $seen = $parent->model("impression")->
                lastSeenAtMessagesScreen($messages_groups);
            $now = time();
            foreach ($seen as $contact_id => $seen_at) {
                $ago = $now - $seen_at;
                foreach (C\MESSAGES_PRESENCE_BANDS as $band => $within) {
                    if ($ago <= $within) {
                        $presence[$contact_id] = $band;
                        break;
                    }
                }
            }
            foreach ($contact_ids as $contact_id) {
                $call_hash_ids[$contact_id] =
                    $this->callHashId($user_id, $contact_id);
            }
            $ringing = $group_model->callsRingingFor($call_hash_ids,
                $user_id);
        }
        foreach ($contact_ids as $contact_id) {
            $contact_username = $user_model->getUsername(
                $contact_id);
            if (!empty($contact_filter) && strpos($contact_username,
                $contact_filter) === false) {
                continue;
            }
            $icon_url = $user_model->getUserIconUrl($contact_id);
            $chat_id = $feed_model->getGroupThreadId(
                $personal_group_id, $user_id,
                $feed_model->getMessagesThreadTitle(
                [$user_id, $contact_id]));
            if (empty($chat_id)) {
                continue;
            }
            $chat_stamp = $impression_model->mostRecentThreadView($user_id,
                $chat_id);
            if (empty($chat_stamp)) {
                $chat_stamp = 0;
            }
            $call_hash_id = $call_hash_ids[$contact_id] ?? "";
            $contacts[$contact_id] = [
                "USER_NAME" => $contact_username,
                "ICON_URL" => $icon_url,
                "PRESENCE" => $presence[$contact_id] ?? "",
                "RINGING" => isset($ringing[$call_hash_id]),
                "NUM_UNREAD_MESSAGES" => floor($feed_model->getThreadPostCount(
                    $chat_id, $chat_stamp)/2)
                ];
        }
        return $contacts;
    }
    /**
     * callHashId gives the identifier a call between two people is
     * recorded under. The two ids are sorted before hashing so either
     * side works out the same identifier for the call between them.
     * @param int $user_id id of one party
     * @param int $contact_id id of the other party
     * @return string the call's identifier
     */
    public function callHashId($user_id, $contact_id)
    {
        $members = [$user_id, $contact_id];
        asort($members);
        return L\crawlHash(implode("-", $members));
    }
    /**
     * ackCallEvent answers a call-signal request with a small acknowledgment
     * and ends the request. The sender never reads the body, so all it needs is
     * a reply that is not the conversation.
     * @param object $parent the controller, for its web site's header call
     */
    private function ackCallEvent($parent)
    {
        $parent->web_site->header("Content-Type: application/json");
        $parent->web_site->header("Cache-Control: no-cache");
        echo json_encode(["ok" => true]);
        \seekquarry\atto\webExit();
    }
    /**
     * ringingContacts answers with the contacts whose calls are waiting to
     * be picked up by the reader, as a small piece of JSON. A page other
     * than the messages screen asks this every few seconds so it can make
     * the messages activity pulse while a call waits, and the messages
     * screen itself asks it so a contact who is not the selected one can
     * be marked. Where calling is turned off the answer is always empty,
     * and the pages do not ask at all.
     * @param int $user_id id of the signed-in reader the calls ring at
     * @param array $data view data as assembled so far, unused here since
     *     the answer is written and the request ended
     */
    private function ringingContacts($user_id, $data)
    {
        $parent = $this->parent;
        $ringing = [];
        $present = [];
        $mine = [];
        $wants_presence = !empty($_REQUEST['presence']);
        if ($this->callServiceMode() !== 'off') {
            $group_model = $parent->model("group");
            $user_model = $parent->model("user");
            $user_messages_id = $group_model->getPersonalGroupId($user_id);
            $parent->model("impression")->add($user_id,
                $user_messages_id, C\GROUP_IMPRESSION);
            $contact_ids = array_diff($group_model->getGroupUserIds(
                $user_messages_id), [$user_id]);
            $call_hash_ids = [];
            foreach ($contact_ids as $contact_id) {
                $call_hash_ids[$contact_id] =
                    $this->callHashId($user_id, $contact_id);
            }
            /* Which phase the call with each contact is in, so a screen
               in a call can tell that the call has gone even where the
               signal saying so never arrived. */
            foreach ($call_hash_ids as $contact_id => $call_hash_id) {
                $mine[$contact_id] = $group_model->callPhase(
                    $call_hash_id);
            }
            $calling = $group_model->callsRingingFor($call_hash_ids,
                $user_id);
            foreach ($contact_ids as $contact_id) {
                $call_hash_id = $call_hash_ids[$contact_id];
                if (isset($calling[$call_hash_id])) {
                    $ringing[] = ["CONTACT_ID" => $contact_id,
                        "USER_NAME" => $user_model->getUsername(
                        $contact_id)];
                }
            }
            if ($wants_presence) {
                $messages_groups = [];
                foreach ($contact_ids as $contact_id) {
                    $messages_groups[$contact_id] =
                        $group_model->getPersonalGroupId($contact_id);
                }
                $seen = $parent->model("impression")->
                    lastSeenAtMessagesScreen($messages_groups);
                $now = time();
                foreach ($seen as $contact_id => $seen_at) {
                    $ago = $now - $seen_at;
                    foreach (C\MESSAGES_PRESENCE_BANDS as $band =>
                        $within) {
                        if ($ago <= $within) {
                            $present[$contact_id] = $band;
                            break;
                        }
                    }
                }
            }
        }
        $parent->web_site->header("Content-Type: application/json");
        $parent->web_site->header("Cache-Control: no-cache");
        $answer = ["ringing" => $ringing, "phase" => $mine];
        if ($wants_presence) {
            $answer["present"] = $present;
        }
        echo json_encode($answer);
        \seekquarry\atto\webExit();
    }
    /**
     * messagesStatus get messages since last status update as determined by
     * session variable $user_id and $data['CONTACT_ID']. Add them to a list in
     * $data['MESSAGES']
     * @param int $user_id user we are trying to update conversation of
     * @param array $data this will be data sent to the view, but should be
     *     called after the 'CONTACT_ID' field has been set to the value of the
     *     person who $user_id is  have a messaging exchange
     * @return array $data updated view data with new messages (if any)
     */
    private function messagesStatus($user_id, $data)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $feed_model = $parent->model("feed");
        $data["REFRESH"] = "api";
        $data["STATUS"] = true;
        $data['MESSAGES'] = [];
        $contact_id = $data['CONTACT_ID'] ?? "";
        $type = $_REQUEST['type'] ?? "status";
        $type = in_array($type, ['status', 'call-event']) ?
            $type : 'status';
        $call_hash_id = $this->callHashId($user_id, $contact_id);
        /* While a call is being rung the caller's browser polls for
           status. Move the call's time up on each such poll so a call
           still ringing does not age out before the other side picks up:
           a person can take longer to answer than the age a call is held
           active for, and without this the call went inactive between
           the ring and the pickup, so the answer's offer arrived to no
           active call and the two sides never joined. Only refresh a
           call that is already there; a poll never starts one. */
        if ($type == 'status' &&
            $group_model->getGroupCallTime($call_hash_id) != "-1") {
            $group_model->refreshGroupCall($call_hash_id,
                L\microTimestamp());
        }
        $call_active = $group_model->isActiveCall($call_hash_id);
        if($call_active) {
            $call_time = $group_model->getGroupCallTime($call_hash_id);
            $group_model->cullCallEvents($call_hash_id);
        }
        $event = null;
        if ($type == 'call-event') {
            /* The event rides the request body as JSON. Under the atto
               server the body is in $_SERVER['CONTENT'] and
               php://input is the process's own input, which is empty,
               so under it every event was arriving as null. Under an
               outside web server it is the other way around. */
            $raw_event = $_SERVER['CONTENT'] ??
                file_get_contents('php://input');
            $event = json_decode($raw_event, true);
        }
        if ($call_active) {
            $call_time = $group_model->getGroupCallTime($call_hash_id);
            if ($type == 'status') {
                $parent->web_site->header("X-Accel-Buffering: no");
                $parent->web_site->header("Content-Type: text/event-stream");
                $parent->web_site->header("Cache-Control: no-cache");
                $_SESSION["PROCESS_TIME"] ??= 0;
                $event_info = $group_model->nextCallEvent($call_hash_id,
                    $user_id, $_SESSION["PROCESS_TIME"]);
                if ($event_info) {
                    echo 'data: ' . json_encode($event_info['EVENT']) .
                        PHP_EOL;
                    $_SESSION["PROCESS_TIME"] = $event_info['EVENT_TIME'];
                }
                echo 'retry: 3000' . PHP_EOL . PHP_EOL;
                \seekquarry\atto\webExit();
            }  else if ($type == 'call-event') {
                $group_model->addCallEvent($call_hash_id, $user_id,
                    $event['type'] ?? "", $event['data'] ?? null);
                /* An end from either side ends the call for both. The
                   event is kept so the other side reads it and closes
                   its own connection, and the call row goes, so nothing
                   reads the call as still waiting to be picked up. */
                if (($event['type'] ?? "") == 'call-end') {
                    $group_model->deleteGroupCall($call_hash_id);
                }
                $this->ackCallEvent($parent);
            }
        } else if ($type == 'call-event') {
            /* No call is running. A call-starting event opens one; an
               ending or empty event has nothing to end and makes
               nothing. Either way the answer is a small ack: answering
               with the conversation, as falling through here did, sent
               the whole of it back for a thirty-byte event. */
            if (!empty($event['type']) && $event['type'] != 'call-end') {
                $group_model->deleteGroupCall($call_hash_id);
                $call_time =
                    $group_model->createGroupCall($call_hash_id, $user_id);
                $group_model->addCallEvent($call_hash_id, $user_id,
                    $event['type'], $event['data'] ?? null);
                $_SESSION["PROCESS_TIME"] = $call_time;
            }
            $this->ackCallEvent($parent);
        }
        $message_thread_id = $feed_model->getGroupThreadId(
            $data['USER_MESSAGES_ID'], $user_id,
            $feed_model->getMessagesThreadTitle(
            [$user_id, $data["CONTACT_ID"]]));
        $conversation_time = $_SESSION['CONVERSATION_TIME'] ?? 0;
        $_SESSION['CONVERSATION_TIME'] = time();
        if (!$message_thread_id || !$conversation_time) {
            return $data;
        }
        $search_array = [
         ["parent_id", "=", $message_thread_id, ""],
         ["group_id", "=", $data['USER_MESSAGES_ID'], ""],
         ["pubdate", ">", $conversation_time, "DESC"]];
         $limit = 0;
        list(, $data['MESSAGES']) = $this->initializeFeedItems($data,
            [], $user_id, $search_array, -2, "krsort", $limit,
            100);
        return $data;
    }

    /**
     * ignoreContact when a contact request is made the receiving user can
     * either accept, ignore, or block. Accept means connection is made, ignore
     * means the connection request is removed from the list of request, but the
     * requestor could send a new request, and block means that a new request
     * will automatically be discarded. This method implements the ignore
     * connection request for a user with id $user_id from a user
     * $data['CONTACT_ID'].
     * @param int $user_id id of user who is doing the ignoring
     * @param array $data current data to be sent to view after processing needs
     *     to contain a field $data['CONTACT_ID'] with the contact_id of user to
     *     ignore
     * @return mixed redirectWithMessage call result; under HTTP this exits
     *     without returning
     */
    private function ignoreContact($user_id, $data)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $feed_model = $parent->model("feed");
        $user_model = $parent->model("user");
        $contact_id = $data['CONTACT_ID'] ?? "";
        if (empty($contact_id)) {
            return $parent->redirectWithMessage(
                tl('social_component_invalid_contact'));
        }
        if ($user_id == C\PUBLIC_USER_ID || $user_id == $contact_id) {
            return $parent->redirectWithMessage(
                tl('social_component_invalid_user'));
        }
        $user_messages_id = $group_model->getPersonalGroupId($user_id);
        if (!empty($contact_id) &&
            !in_array($user_model->getUserStatus($contact_id), [
            C\ACTIVE_STATUS, C\EDITOR_STATUS])) {
            return $parent->redirectWithMessage(
                tl('social_component_invalid_contact'));
        }
        $request_thread_title = $feed_model->getMessagesThreadTitle(
            [$user_id]);
        $request_thread_id = $feed_model->getGroupThreadId(
            $user_messages_id, $contact_id, $request_thread_title);
        if (!empty($request_thread_id)) {
            $feed_model->deleteGroupItem($request_thread_id, $contact_id);
        }
        return $parent->redirectWithMessage(
            tl('social_component_connection_request_ignored'));
    }
    /**
     * blockContact when a contact request is made the receiving user can either
     * accept, ignore, or block. Accept means connection is made, ignore means
     * the connection request is removed from the list of request, but the
     * requestor could send a new request, and block means that a new request
     * will automatically be discarded. This method implements the block
     * connection request for a user with id $user_id from a user
     * $data['CONTACT_ID'].
     * @param int $user_id id of user who is doing the blocking
     * @param array $data current data to be sent to view after processing needs
     *     to contain a field $data['CONTACT_ID'] with the contact_id of user to
     *     block
     * @return mixed redirectWithMessage call result; under HTTP this exits
     *     without returning
     */
    private function blockContact($user_id, $data)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $feed_model = $parent->model("feed");
        $user_model = $parent->model("user");
        $contact_id = $data['CONTACT_ID'] ?? "";
        if (empty($contact_id)) {
            return $parent->redirectWithMessage(
                tl('social_component_invalid_contact'));
        }
        if ($user_id == C\PUBLIC_USER_ID || $user_id == $contact_id) {
            return $parent->redirectWithMessage(
                tl('social_component_invalid_user'));
        }
        $user_messages_id = $group_model->getPersonalGroupId($user_id);
        if (!empty($contact_id) &&
            !in_array($user_model->getUserStatus($contact_id), [
            C\ACTIVE_STATUS, C\EDITOR_STATUS]) ) {
            return $parent->redirectWithMessage(
                tl('social_component_invalid_contact'));
        }
        $contact_messages_id = $group_model->getPersonalGroupId($contact_id);
        $request_thread_title = $feed_model->getMessagesThreadTitle(
            [$user_id]);
        $request_thread_id = $feed_model->getGroupThreadId(
            $user_messages_id, $contact_id, $request_thread_title);
        if (!empty($request_thread_id)) {
            $feed_model->deleteGroupItem($request_thread_id, $contact_id);
            $block_thread_title = $feed_model->getMessagesThreadTitle(
                [$contact_id, "blocked"]);
            $this->addGroupItemWithinLimits(0,
                $contact_messages_id, $user_id, $block_thread_title,
                "");
        }
        return $parent->redirectWithMessage(
            tl('social_component_connection_request_blocked'));
    }
    /**
     * deleteMessage removes one message from a conversation. The model only
     * removes a message the person wrote, unless they are root, so a person
     * cannot take away what the other side said.
     * @param int $user_id who is asking
     * @param array $data field variables for the view
     * @return array $data with the conversation as it now stands
     */
    private function deleteMessage($user_id, $data)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $feed_model = $parent->model("feed");
        $message_id = $parent->clean($_REQUEST['message_id'] ?? "",
            "int");
        if (!empty($message_id)) {
            $user_messages_id = $group_model->getPersonalGroupId($user_id);
            $gone = $group_model->deleteMessageCopy($message_id, $user_id,
                $user_messages_id);
            if ($gone) {
                /* Anything attached to the message goes with it, since
                   nothing points at it any more. */
                $wiki_model->deleteResources($user_messages_id,
                    "post" . $message_id);
                return $parent->redirectWithMessage(
                    tl('social_component_message_deleted'),
                    ["contact_id"]);
            }
            return $parent->redirectWithMessage(
                tl('social_component_message_not_deleted'),
                ["contact_id"]);
        }
        $_REQUEST['arg'] = "";
        return $this->userMessages();
    }
    /**
     * deleteConversation lets go of every message the signed-in user holds in a
     * conversation with $data['CONTACT_ID'], leaving the other person's copies
     * as they were. The two remain contacts with an empty conversation between
     * them.
     * @param int $user_id id of the user clearing the conversation
     * @param array $data current data to be sent to the view, holding the
     *     CONTACT_ID of the other person
     * @return mixed redirectWithMessage call result; under HTTP this exits
     *     without returning
     */
    private function deleteConversation($user_id, $data)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $feed_model = $parent->model("feed");
        $contact_id = $data['CONTACT_ID'] ?? "";
        if (empty($contact_id)) {
            $_REQUEST['arg'] = "";
            return $this->userMessages();
        }
        $user_messages_id = $group_model->getPersonalGroupId($user_id);
        $thread_id = $feed_model->getGroupThreadId($user_messages_id,
            $user_id, $feed_model->getMessagesThreadTitle(
            [$user_id, $contact_id]));
        if (empty($thread_id)) {
            return $parent->redirectWithMessage(
                tl('social_component_conversation_not_deleted'),
                ["contact_id"]);
        }
        $group_model->deleteConversationCopies($thread_id,
            $user_messages_id);
        return $parent->redirectWithMessage(
            tl('social_component_conversation_deleted'), ["contact_id"]);
    }
    /**
     * removeContact takes someone off the signed-in user's list of contacts.
     * Their messages to each other go from this user's side, along with the row
     * the conversation hung from and the other person's place in this user's
     * message group. The other person keeps their own copy of the conversation.
     * @param int $user_id id of the user removing the contact
     * @param array $data current data to be sent to the view, holding the
     *     CONTACT_ID of the person being removed
     * @return mixed redirectWithMessage call result; under HTTP this exits
     *     without returning
     */
    private function removeContact($user_id, $data)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $feed_model = $parent->model("feed");
        $contact_id = $data['CONTACT_ID'] ?? "";
        if (empty($contact_id)) {
            $_REQUEST['arg'] = "";
            return $this->userMessages();
        }
        $user_messages_id = $group_model->getPersonalGroupId($user_id);
        $thread_title = $feed_model->getMessagesThreadTitle(
            [$user_id, $contact_id]);
        $thread_id = $feed_model->getGroupThreadId($user_messages_id,
            $user_id, $thread_title);
        if (!empty($thread_id)) {
            $group_model->deleteConversationCopies($thread_id,
                $user_messages_id);
        }
        $feed_model->deleteConversationThreadRow($user_messages_id,
            $thread_title);
        $group_model->deleteUserGroup($contact_id, $user_messages_id);
        return $parent->redirectWithMessage(
            tl('social_component_contact_removed'));
    }
    /**
     * newMessage sends a message cleaned $_REQUEST["description"] from user
     * with id $user_id to user with $data['CONTACT_ID'] Expects a message
     * $_REQUEST["description"] coming from message form to to send.
     * @param int $user_id id of user who is sending the message
     * @param array $data current data to be sent to view after processing needs
     *     to contain a field $data['CONTACT_ID'] with the contact_id of user to
     *     send to
     * @return mixed redirectWithMessage call result; under HTTP this exits
     *     without returning
     */
    private function newMessage($user_id, $data)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $feed_model = $parent->model("feed");
        $user_messages_id = $group_model->getPersonalGroupId($user_id);
        $messages_thread_title = $feed_model->getMessagesThreadTitle(
            [$user_id, $data['CONTACT_ID']]);
        $description = $contact_id = $parent->clean($_REQUEST["description"]
            ?? "", 'string');
        $message_thread_id = $feed_model->getGroupThreadId($user_messages_id,
            $user_id, $messages_thread_title);
        $parent->model("impression")->add($user_id, $message_thread_id,
            C\THREAD_IMPRESSION);
        if (empty($message_thread_id)) {
            return $parent->redirectWithMessage(
                tl("social_component_invalid_message_thread"),
                ['contact_id']);
        }
        $thread_contributors = explode("-", $messages_thread_title);
        $first_group_id = -1;
        $result = self::UPLOAD_NO_FILES;
        foreach ($thread_contributors as $contributor_id) {
            $contributor_personal_id = $group_model->
                getPersonalGroupId($contributor_id);
            if (empty($contributor_personal_id)) {
                continue;
            }
            $message_id = $this->addGroupItemWithinLimits($message_thread_id,
                $contributor_personal_id, $user_id,
                "--" . $messages_thread_title, $description);
            if ($first_group_id == -1) {
                $first_group_id = $contributor_personal_id;
                $first_message_id = $message_id;
                $result = $this->handleResourceUploads($contributor_personal_id,
                    "post" . $message_id);
                if ($result == self::UPLOAD_FAILED) {
                    return $parent->redirectWithMessage(
                        tl('social_component_upload_error'));
                }
            } else if ($result == self::UPLOAD_SUCCESS) {
                $wiki_model->linkResourceFolders($contributor_personal_id,
                    "post" . $message_id, $first_group_id,
                    "post" . $first_message_id);
            }
        }
        return $parent->redirectWithMessage("", ['contact_id']);
    }
    /**
     * loadMoreMessages loads more messages for a conversation to support
     * infinite scroll pagination. Returns JSON response with older messages.
     * @param int $user_id id of user requesting more messages
     * @param array $data current data array (not used but required by
     *     interface)
     */
    private function loadMoreMessages($user_id, $data)
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $feed_model = $parent->model("feed");
        $user_model = $parent->model("user");
        $contact_id = $parent->clean($_REQUEST['contact_id'] ?? 0, 'int');
        $before_timestamp = $parent->clean(
            $_REQUEST['before_timestamp'] ?? 0, 'int');
        $limit = $parent->clean($_REQUEST['limit'] ?? 20, 'int');
        if (empty($contact_id) || empty($before_timestamp)) {
            $parent->web_site->header('Content-Type: application/json');
            e(json_encode(['error' => 'Missing required parameters',
                'contact_id' => $contact_id,
                'before_timestamp' => $before_timestamp]));
            \seekquarry\atto\webExit();
        }
        if ($limit > 100) {
            $limit = 100;
        }
        $user_messages_id = $group_model->getPersonalGroupId($user_id);
        $messages_thread_title = $feed_model->getMessagesThreadTitle(
            [$user_id, $contact_id]);
        $message_thread_id = $feed_model->getGroupThreadId($user_messages_id,
            $user_id, $messages_thread_title);
        if (empty($message_thread_id)) {
            $parent->web_site->header('Content-Type: application/json');
            e(json_encode(['error' => 'Invalid message thread']));
            \seekquarry\atto\webExit();
        }
        $search_array = [
            ["parent_id", "=", $message_thread_id, ""],
            ["group_id", "=", $user_messages_id, ""],
            ["pubdate", "<", $before_timestamp, "DESC"]
        ];
        $temp_data = $data;
        $temp_limit = 0;
        try {
            list(, $messages) = $this->initializeFeedItems($temp_data, [],
                $user_id, $search_array, -2, "krsort", $temp_limit, $limit);
        } catch (Exception $e) {
            $messages = $feed_model->getGroupItems(0, $limit,
                $search_array, $user_id, -2);
        }
        $has_more = count($messages) == $limit;
        $element_data = [
            'MESSAGES' => $messages,
            'USERNAME' => $user_model->getUsername($user_id),
            'CONTACT_ID' => $contact_id,
            'CONTACT_NAME' => $user_model->getUsername($contact_id),
            'CONTACT_ICON_URL' => $user_model->getUserIconUrl($contact_id),
            'MOBILE' => false,
            'LOCALE_DIR' => 'ltr'
        ];
        $rendered_html = "";
        $now = time();
        $old_pubdate = $now;
        $old_user = $messages[0]['USER_NAME'] ?? "";
        $num_messages = count($messages);
        $rendered_count = 0;
        for ($i = 0; $i < $num_messages; $i++) {
            $message_info = $messages[$i];
            $next_message_info = $messages[$i + 1] ?? $message_info;
            $next_user_name = $next_message_info['USER_NAME'];
            $description_content = '';
            if (!empty($message_info['DESCRIPTION'])) {
                $description_content = $message_info['DESCRIPTION'];
            } else {
                foreach (['t', 'DESCRIPTION', 'OLD_DESCRIPTION',
                    'SOURCE_NAME', 'TITLE'] as $field) {
                    if (!empty($message_info[$field])) {
                        $description_content = $message_info[$field];
                        break;
                    }
                }
            }
            if (empty($description_content)) {
                continue;
            }
            $rendered_count++;
            $pub_date = $message_info['PUBDATE'];
            $next_pub_date = $next_message_info['PUBDATE'];
            $pub_date_diff = abs($next_pub_date - $pub_date);
            $pub_date_age = $now - $pub_date;
            $user_change = $message_info['USER_NAME'] != $next_user_name;
            $pub_data_change = $user_change ||($pub_date_age < C\ONE_HOUR &&
                $pub_date_diff > 5 * C\ONE_MINUTE) ||
                ($pub_date_age < C\ONE_DAY && $pub_date_diff > C\ONE_HOUR)||
                ($pub_date_diff > C\ONE_DAY);
            $pub_date_string = date('M j, Y g:i A', $pub_date);
            $color = "message-theirs";
            $align = "align-same";
            $is_user = false;
            $user_string = "other";
            $top_corner = " top-corner ";
            if ($element_data['USERNAME'] == $message_info['USER_NAME']) {
                $color = "message-mine";
                $align = "align-opposite";
                $is_user = true;
                $user_string = "user";
                $top_corner =  "";
            }
            $comma = "";
            $time_content = "";
            if (!$is_user && ($pub_data_change || $user_change)) {
                $time_content .= '<img class="message-icon float-same" src="'.
                    htmlentities($message_info['USER_ICON']).'" >&nbsp;';
                $comma = ", ";
                $time_content .= htmlentities($message_info['USER_NAME']);
            }
            if ($pub_data_change) {
                $time_content .= htmlentities($comma . $pub_date_string);
            }
            $rendered_html .= '<div class="' . htmlentities($align) . '">' .
                '<div class="message-detail-outer">' .
                '<div class="message-' . htmlentities($user_string) .
                    '-time">' .
                $time_content . '</div>' .
                '<div class="message-detail ' .
                    htmlentities($color . $top_corner) . '">' .
                $description_content . '</div>' .
                '</div></div>';
        }
        $oldest_timestamp = null;
        if (!empty($messages)) {
            $oldest_timestamp = $messages[count($messages) - 1]['PUBDATE'];
        }
        $parent->web_site->header('Content-Type: application/json');
        $response = json_encode([
            'html' => $rendered_html,
            'has_more' => $has_more,
            'message_count' => count($messages),
            'oldest_timestamp' => $oldest_timestamp
        ]);
        e($response);
        \seekquarry\atto\webExit();
    }
}
X