_binary From c656945ab3a2e4c25297c5ad5d2a730609499fe7 Mon Sep 17 00:00:00 2001
From: Aditya <thesyn3rman@gmail.com>
Date: Tue, 25 Nov 2025 09:25:40 -0800
Subject: [PATCH] snake case variable names

Signed-off-by: Aditya <thesyn3rman@gmail.com>
---
 src/controllers/ApiController.php             |  93 ++++-
 .../components/SocialComponent.php            | 355 +++++++++++++++++-
 src/css/messages.css                          |  14 +
 src/locale/en_US/configure.ini                |   2 +
 src/models/GroupModel.php                     |  59 +++
 src/scripts/messages.js                       |  93 +++++
 src/views/elements/UsermessagesElement.php    |  54 ++-
 7 files changed, 662 insertions(+), 8 deletions(-)

diff --git a/src/controllers/ApiController.php b/src/controllers/ApiController.php
index 6a96afabc..047756f49 100644
--- a/src/controllers/ApiController.php
+++ b/src/controllers/ApiController.php
@@ -55,7 +55,8 @@ class ApiController extends Controller implements CrawlConstants
      * These are the activities supported by this controller
      * @var array
      */
-    public $activities = ["summarize", "transcribe", "transcriptionStatus"];
+    public $activities = ["summarize", "transcribe", "transcriptionStatus",
+        "translate"];
     /**
      * Used to process requests related to user group activities outside of
      * the admin panel setting. This either could be because the admin panel
@@ -423,6 +424,96 @@ class ApiController extends Controller implements CrawlConstants
         $this->returnJsonResponse($data);
         return $data;
     }
+    /**
+     * Handles a request to translate text from one language to another
+     *
+     * @return array $data containing translation results and status
+     */
+    public function translate()
+    {
+        $data = [];
+        $data['ELEMENT'] = 'translate';
+        if (empty($_REQUEST['text']) || empty($_REQUEST['target_lang'])) {
+            $data['errors'] = ["Missing required parameters: 'text' and " .
+                "'target_lang' are required"];
+            return $data;
+        }
+        $text = $this->clean($_REQUEST['text'], "string");
+        $target_lang = $this->clean($_REQUEST['target_lang'], "string");
+
+        return $this->translateText($text, $target_lang);
+    }
+    /**
+     * Translates a given text to the specified target language.
+     *
+     * @param string $text The text to translate
+     * @param string $target_lang The target language code
+     * @return array Translation results with status and content
+     */
+    public function translateText($text, $target_lang)
+    {
+        $data = [];
+        $data['ELEMENT'] = 'translate';
+        if (empty($text) || empty($target_lang)) {
+            $data['errors'] = ["Missing required parameters: 'text' and " .
+                "'target_lang' are required"];
+            return $data;
+        }
+        $api_url = C\LLM_API_URL ??
+            "http://localhost:1234/v1/chat/completions";
+        $model = C\LLM_MODEL ?? "aya-23-8b";
+        $language_map = [
+            "ar" => "Arabic", "bn" => "Bengali", "de" => "German",
+            "el_GR" => "Greek", "en_US" => "English (US)", "es" => "Spanish",
+            "fa" => "Persian", "fr_FR" => "French (France)", "he" => "Hebrew",
+            "hi" => "Hindi", "id" => "Indonesian", "it" => "Italian",
+            "ja" => "Japanese", "kn" => "Kannada", "ko" => "Korean",
+            "nl" => "Dutch", "pl" => "Polish", "pt" => "Portuguese",
+            "ru" => "Russian", "te" => "Telugu", "th" => "Thai",
+            "tl" => "Tagalog", "tr" => "Turkish", "vi_VN" => "Vietnamese",
+            "zh_CN" => "Chinese (Simplified)"
+        ];
+        $mapping_string = "Use the following language mapping for " .
+            "accurate translations:\n";
+        foreach ($language_map as $code => $lang) {
+            $mapping_string .= "$code → $lang\n";
+        }
+        $translate_prompt = "Translate the text that is in following " .
+            "sequence of <in></in> tags to the locale " . $target_lang .
+            ". Assume the context is text that might appear in a " .
+            "chat message. You should decode any html entities before " .
+            "doing the translation. If you see text like '%s' in the " .
+            "input treat it like a placeholder that should appear " .
+            "verbatim in the appropriate place in the output. Output " .
+            "the results between <out></out> tags. Do not add any " .
+            "explanations. Do not repeat the output translations. " .
+            "<in>" . $text . "</in>";
+        $request_body = ["model" => $model, "messages" => [["role" => "system",
+            "content" => "You are a helpful assistant who translates text" .
+            " from one language to another. Use this predefined mapping " .
+            "for accurate language detection:\n" . $mapping_string],
+            ["role" => "user", "content" => $translate_prompt]],
+            "temperature" => 0.1, "max_tokens" => -1, "stream" => false];
+        $result = $this->sendLLMRequest($api_url, $request_body);
+        if (!$result) {
+            $data['errors'] = ["Failed to connect to translation service"];
+        } else {
+            $result_obj = json_decode($result, true);
+            if (isset($result_obj['choices'][0]['message']['content'])) {
+                $content = $result_obj['choices'][0]['message']['content'];
+                preg_match('/<out>([\s\S]*?)<\/out>/', $content, $matches);
+                if (isset($matches[1])) {
+                    $data['translation'] = trim($matches[1]);
+                    $data['status'] = 'success';
+                } else {
+                    $data['errors'] = ["Translation format not recognized"];
+                }
+            } else {
+                $data['errors'] = ["Invalid response from translation service"];
+            }
+        }
+        return $data;
+    }
     /**
      * Helper method to return JSON response with proper headers
      *
diff --git a/src/controllers/components/SocialComponent.php b/src/controllers/components/SocialComponent.php
index 74ed024d4..a2f39eaac 100644
--- a/src/controllers/components/SocialComponent.php
+++ b/src/controllers/components/SocialComponent.php
@@ -2368,10 +2368,22 @@ class SocialComponent extends Component implements CrawlConstants
         $data['CONTACT_ICON_URL'] = $user_model->getUserIconUrl($contact_id);
         $user_messages_id = $group_model->getPersonalGroupId($user_id);
         $data['USER_MESSAGES_ID'] = $user_messages_id;
+        if (!empty($contact_id)) {
+            $data['TRANSLATION_LANGUAGE'] =
+                $group_model->getTranslationLanguage($user_id, $contact_id);
+        }
+        if (!empty(C\LLM_API_URL) && !empty(C\LLM_MODEL)) {
+            $locale_model = $parent->model("locale");
+            $data['AVAILABLE_LOCALES'] =
+                $this->getTranslationLocales($locale_model);
+            $data['CURRENT_LOCALE_TAG'] = L\getLocaleTag();
+        }
         $message_actions = ['addcontact' => 'addContact',
             'blockcontact' => 'blockContact',
             'ignorecontact' => 'ignoreContact',
-            'newmessage' => 'newMessage', "status" =>
+            'newmessage' => 'newMessage',
+            'loadmessages' => 'loadMoreMessages',
+            'settranslation' => 'setTranslationLanguage', "status" =>
             "messagesStatus"];
         $arg = $_REQUEST['arg'] ?? "";
         if (in_array($arg, array_keys($message_actions))) {
@@ -2402,7 +2414,9 @@ class SocialComponent extends Component implements CrawlConstants
         $data['SCRIPT'] .=  "window.tl = {".
             'social_component_no_longer_update:"'.
                 tl('social_component_no_longer_update').'"'.
-            '}; window.onload = doUpdate;';
+            '}; window.USERNAME = "' . $user_model->getUsername($user_id) . '";'.
+            'window.CSRF_TOKEN = "' . C\CSRF_TOKEN . '";'.
+            'window.onload = doUpdate;';
         $contact_ids = array_diff($group_model->getGroupUserIds(
             $user_messages_id), [$user_id]);
         $contacts = $this->marshallContactInfo($user_id, $contact_ids,
@@ -2446,7 +2460,26 @@ class SocialComponent extends Component implements CrawlConstants
                  ["pubdate", "", "", "DESC"]];
                 list(, $data['MESSAGES']) = $this->initializeFeedItems($data,
                     [], $user_id, $search_array, -2, "krsort", $limit,
-                    100);
+                    15);
+                if (!empty(C\LLM_API_URL) && !empty(C\LLM_MODEL)) {
+                    $translation_target = null;
+                    $translation_lang = $data['TRANSLATION_LANGUAGE'] ?? '';
+                    if (!empty($translation_lang) &&
+                        $translation_lang !== 'off') {
+                        $translation_target = $translation_lang;
+                    } elseif (empty($translation_lang) ||
+                        $translation_lang === 'off') {
+                        $user_locale = $data['CURRENT_LOCALE_TAG'] ?? 'en_US';
+                        if ($user_locale !== 'en_US' &&
+                            $user_locale !== C\DEFAULT_LOCALE) {
+                            $translation_target = $user_locale;
+                        }
+                    }
+                    if (!empty($translation_target)) {
+                        $data['MESSAGES'] = $this->translateMessageBatch(
+                            $data['MESSAGES'], $translation_target);
+                    }
+                }
             }
         }
         $this->initSocialBadges($user_id, $data);
@@ -2756,6 +2789,160 @@ class SocialComponent extends Component implements CrawlConstants
         return $parent->redirectWithMessage(
             tl('social_component_connection_request_blocked'));
     }
+    /**
+     * Sets the translation language for messages from a specific contact
+     *
+     * @param int $user_id id of user who will receive translated messages
+     * @param array $data current data to be sent to view after processing
+     */
+    private function setTranslationLanguage($user_id, $data)
+    {
+        $parent = $this->parent;
+        $group_model = $parent->model("group");
+        $contact_id = $data['CONTACT_ID'] ?? null;
+        $language = $parent->clean($_REQUEST['language'] ?? 'off', 'string');
+        if (empty($contact_id)) {
+            return $parent->redirectWithMessage(
+                tl('social_component_invalid_contact'),
+                ['contact_id']
+            );
+        }
+        $group_model->setTranslationLanguage($user_id, $contact_id, $language);
+        $message_key = ($language == 'off')
+            ? 'social_component_translation_disabled'
+            : 'social_component_translation_enabled';
+        return $parent->redirectWithMessage(tl($message_key), ['contact_id']);
+    }
+    /**
+     * Translates a batch of messages using the LLM API
+     *
+     * @param array $messages Array of message items to translate
+     * @param string $target_language Target language code
+     * @return array Array of messages with translated content
+     */
+    private function translateMessageBatch($messages, $target_language)
+    {
+        if (empty($messages) || empty($target_language) ||
+            $target_language == 'off') {
+            return $messages;
+        }
+        $translated_count = 0;
+        foreach ($messages as $index => &$message) {
+            $content_field = null;
+            $original_text = null;
+            if (!empty($message['t'])) {
+                $content_field = 't';
+                $original_text = $message['t'];
+            } else if (!empty($message['DESCRIPTION'])) {
+                $content_field = 'DESCRIPTION';
+                $original_text = $message['DESCRIPTION'];
+            }
+            if ($content_field && $original_text) {
+                $clean_text = strip_tags($original_text);
+                $clean_text = trim($clean_text);
+                $is_media_message = (
+                    strpos($original_text, '<audio') !== false ||
+                    strpos($original_text, '<video') !== false ||
+                    strpos($original_text, '<img') !== false ||
+                    strpos($original_text, 'audio-message-container') !==
+                        false ||
+                    strpos($original_text, 'video-message-container') !==
+                        false ||
+                    strpos($original_text, 'image-message-container') !==
+                        false ||
+                    strpos($original_text, 'audio-message') !== false ||
+                    strpos($original_text, 'video-message') !== false ||
+                    strpos($original_text, 'image-message') !== false ||
+                    strpos($clean_text, 'Description audio_') !== false ||
+                    strpos($clean_text, 'Description video_') !== false ||
+                    strpos($original_text, 'transcribe-btn') !== false ||
+                    strpos($original_text, 'transcript-container') !== false
+                );
+
+                if (strlen($clean_text) > 5 && !ctype_digit($clean_text) &&
+                    !$is_media_message) {
+                    $translation_result = $this->translateText(
+                        $original_text, $target_language);
+                    if (!empty($translation_result['translation'])) {
+                        $message[$content_field] =
+                            $translation_result['translation'];
+                        $message['ORIGINAL_' . $content_field] = $original_text;
+                        $translated_count++;
+                    }
+                }
+            }
+        }
+        unset($message);
+        return $messages;
+    }
+    /**
+     * Gets available translation locales with their native names
+     *
+     * @param object $locale_model Locale model instance
+     * @return array Array of locale codes and their native names
+     */
+    private function getTranslationLocales($locale_model)
+    {
+        $locales = $locale_model->getLocaleList(true, true);
+        $translation_locales = ["off" => tl('toggle_helper_off')];
+        foreach ($locales as $locale) {
+            if (!empty($locale['LOCALE_TAG']) &&
+                !empty($locale['LOCALE_NAME'])) {
+                $translation_locales[$locale['LOCALE_TAG']] =
+                    $locale['LOCALE_NAME'];
+            }
+        }
+        return $translation_locales;
+    }
+    /**
+     * Translates a given text to the specified target language using LLM API
+     *
+     * @param string $text The text to translate
+     * @param string $target_lang The target language code
+     * @return array Translation results with status and content
+     */
+    private function translateText($text, $target_lang)
+    {
+        if (empty($text) || empty($target_lang)) {
+            return ['errors' => ["Missing required parameters"]];
+        }
+        $api_url = C\LLM_API_URL ?? "http://localhost:1234/v1/chat/completions";
+        $model = C\LLM_MODEL ?? "aya-23-8b";
+        $language_map = ["ar" => "Arabic", "bn" => "Bengali", "de" => "German",
+            "el_GR" => "Greek", "en_US" => "English (US)", "es" => "Spanish",
+            "fa" => "Persian", "fr_FR" => "French (France)", "he" => "Hebrew",
+            "hi" => "Hindi", "id" => "Indonesian", "it" => "Italian",
+            "ja" => "Japanese", "kn" => "Kannada", "ko" => "Korean",
+            "nl" => "Dutch", "pl" => "Polish", "pt" => "Portuguese",
+            "ru" => "Russian", "te" => "Telugu", "th" => "Thai",
+            "tl" => "Tagalog", "tr" => "Turkish", "vi_VN" => "Vietnamese",
+            "zh_CN" => "Chinese (Simplified)"];
+        $target_language_name = $language_map[$target_lang] ?? $target_lang;
+        $request_body = ["model" => $model, "messages" => [["role" => "system",
+            "content" => "You are a helpful translation assistant. " .
+            "Translate text accurately while preserving meaning."],
+            ["role" => "user", "content" => "Translate the following text to " .
+            $target_language_name .
+            ". Only return the translation without explanations:\n\n" . $text]],
+            "temperature" => 0.1, "max_tokens" => 512, "stream" => false];
+        $post_data = json_encode($request_body);
+        $headers = ['Content-Type: application/json'];
+        $response = L\FetchUrl::getPage($api_url, $post_data, true, null,
+            C\SINGLE_PAGE_TIMEOUT, $headers);
+        if ($response === false) {
+            return ['errors' => ["Failed to connect to translation service"]];
+        }
+        $result_obj = json_decode($response, true);
+        if (isset($result_obj['response'])) {
+            $content = trim($result_obj['response']);
+            return ['translation' => $content, 'status' => 'success'];
+        } else if (isset($result_obj['choices'][0]['message']['content'])) {
+            $content = trim($result_obj['choices'][0]['message']['content']);
+            return ['translation' => $content, 'status' => 'success'];
+        } else {
+            return ['errors' => ["Invalid response from translation service"]];
+        }
+    }
     /**
      * Sends a message cleaned $_REQUEST["description"] from user with id
      * $user_id to user with $data['CONTACT_ID']
@@ -2815,6 +3002,168 @@ class SocialComponent extends Component implements CrawlConstants
         }
         return $parent->redirectWithMessage("", ['contact_id']);
     }
+    /**
+     * 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");
+        $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)) {
+            header('Content-Type: application/json');
+            echo json_encode(['error' => 'Missing required parameters',
+                'contact_id' => $contact_id,
+                'before_timestamp' => $before_timestamp]);
+            exit();
+        }
+        if ($limit > 100) {
+            $limit = 100;
+        }
+        $user_messages_id = $group_model->getPersonalGroupId($user_id);
+        $messages_thread_title = $group_model->getMessagesThreadTitle(
+            [$user_id, $contact_id]);
+        $message_thread_id = $group_model->getGroupThreadId($user_messages_id,
+            $user_id, $messages_thread_title);
+
+        if (empty($message_thread_id)) {
+            header('Content-Type: application/json');
+            echo json_encode(['error' => 'Invalid message thread']);
+            exit();
+        }
+        $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 = $group_model->getGroupItems(0, $limit,
+                $search_array, $user_id, -2);
+        }
+        if (!empty(C\LLM_API_URL) && !empty(C\LLM_MODEL)) {
+            $translation_language =
+                $group_model->getTranslationLanguage($user_id, $contact_id);
+            $user_locale = L\getLocaleTag();
+            $translation_target = null;
+            if (!empty($translation_language) &&
+                $translation_language !== 'off') {
+                $translation_target = $translation_language;
+            } elseif (empty($translation_language) ||
+                $translation_language === 'off') {
+                if ($user_locale !== 'en_US' &&
+                    $user_locale !== C\DEFAULT_LOCALE) {
+                    $translation_target = $user_locale;
+                }
+            }
+            if (!empty($translation_target)) {
+                $messages = $this->translateMessageBatch($messages,
+                    $translation_target);
+            }
+        }
+        $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 = "back-light-gray";
+            $align = "align-same";
+            $is_user = false;
+            $user_string = "other";
+            $top_corner = " top-corner ";
+            if ($element_data['USERNAME'] == $message_info['USER_NAME']) {
+                $color = "back-light-blue";
+                $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'];
+        }
+        header('Content-Type: application/json');
+        $response = json_encode([
+            'html' => $rendered_html,
+            'has_more' => $has_more,
+            'message_count' => count($messages),
+            'oldest_timestamp' => $oldest_timestamp
+        ]);
+        echo $response;
+        exit();
+    }
     /**
      * Determines a list of posts that might need to reply to a post in
      * a group
diff --git a/src/css/messages.css b/src/css/messages.css
index 2468e8544..a8be2ee61 100644
--- a/src/css/messages.css
+++ b/src/css/messages.css
@@ -657,3 +657,17 @@
     color: #d32f2f;
     font-style: italic;
 }
+
+#translation-language-list {
+    max-height: 150px;
+    overflow-y: auto;
+    border-top: 1px solid #999;
+    border-bottom: 1px solid #999;
+    background: white;
+}
+#translation-language-list li:first-child {
+    border-top: none;
+}
+#translation-language-list li:last-child {
+    border-bottom: none;
+}
diff --git a/src/locale/en_US/configure.ini b/src/locale/en_US/configure.ini
index c7e450e9b..99513da66 100644
--- a/src/locale/en_US/configure.ini
+++ b/src/locale/en_US/configure.ini
@@ -332,6 +332,8 @@ social_component_connection_requested = "Connection Requested!"
 social_component_connection_request_ignored = "Request Ignored!"
 social_component_connection_request_blocked = "Request Blocked!"
 social_component_invalid_message_thread = "Invalid Message Thread!"
+social_component_translation_enabled = "Translation Enabled"
+social_component_translation_disabled = "Translation Disabled"
 social_component_flagged_post = "[%s] %s"
 social_component_join_asc = "Join Date&amp;uarr;"
 social_component_join_desc = "Join Date&amp;darr;"
diff --git a/src/models/GroupModel.php b/src/models/GroupModel.php
index 91b0ae860..44f196e49 100644
--- a/src/models/GroupModel.php
+++ b/src/models/GroupModel.php
@@ -5123,4 +5123,63 @@ EOD;
             EVENT_TIME < ?";
         $result = $db->execute($sql, [$call_group_id, $timestamp]);
     }
+    /**
+     * Retrieves the translation language setting for a specific contact
+     *
+     * @param int $user_id id of the user who owns the setting
+     * @param int $contact_id id of the contact whose messages may be
+     *  translated
+     * @return string|false translation language code or false if not set
+     */
+    public function getTranslationLanguage($user_id, $contact_id)
+    {
+        $db = $this->db;
+        $user_group_id = $this->getPersonalGroupId($user_id);
+        $settings_thread_title = "translate-settings-" . $user_id .
+            "-" . $contact_id;
+        $thread_id = $this->getGroupThreadId($user_group_id, $user_id,
+            $settings_thread_title);
+        if (!$thread_id) {
+            return false;
+        }
+        $sql = "SELECT DESCRIPTION FROM GROUP_ITEM WHERE PARENT_ID = ?";
+        $result = $db->execute($sql, [$thread_id]);
+        $row = $db->fetchArray($result);
+        if (isset($row['DESCRIPTION'])) {
+            $settings = json_decode($row['DESCRIPTION'], true);
+            return $settings['TRANSLATE_LANGUAGE'] ?? false;
+        }
+        return false;
+    }
+    /**
+     * Stores the translation language setting for a specific contact
+     *
+     * @param int $user_id id of the user who owns the setting
+     * @param int $contact_id id of the contact whose messages will be
+     *  translated
+     * @param string $language language code (e.g., 'es', 'fr') or 'off'
+     *  to disable
+     */
+    public function setTranslationLanguage($user_id, $contact_id, $language)
+    {
+        $db = $this->db;
+        $user_group_id = $this->getPersonalGroupId($user_id);
+        $settings_thread_title = "translate-settings-" . $user_id .
+            "-" . $contact_id;
+        $thread_id = $this->getGroupThreadId($user_group_id, $user_id,
+            $settings_thread_title);
+        $settings = json_encode(["TRANSLATE_LANGUAGE" => $language]);
+        if (!$thread_id) {
+            $this->addGroupItem(
+                0,
+                $user_group_id,
+                $user_id,
+                $settings_thread_title,
+                $settings
+            );
+        } else {
+            $sql = "UPDATE GROUP_ITEM SET DESCRIPTION = ? WHERE PARENT_ID = ?";
+            $db->execute($sql, [$settings, $thread_id]);
+        }
+    }
 }
diff --git a/src/scripts/messages.js b/src/scripts/messages.js
index a2ae4bf6d..48982efb9 100644
--- a/src/scripts/messages.js
+++ b/src/scripts/messages.js
@@ -423,3 +423,96 @@ function icecandidate(local_stream)
     };
     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;
+    });
+}
\ No newline at end of file
diff --git a/src/views/elements/UsermessagesElement.php b/src/views/elements/UsermessagesElement.php
index 79900e022..7cd01545a 100644
--- a/src/views/elements/UsermessagesElement.php
+++ b/src/views/elements/UsermessagesElement.php
@@ -239,17 +239,61 @@ class UsermessagesElement extends Element implements CrawlConstants
                 $data["CONTACT_ICON_URL"]; ?>"
                 alt="<?=tl('usermessages_element_user_icon'); ?>" >
                 <span><?= $data['CONTACT_NAME'] ?></span>
+                <?php
+                if (!empty($data['AVAILABLE_LOCALES'])) {
+                $current_language = $data['TRANSLATION_LANGUAGE'] ?? 'off';
+                $translation_url = $messages_url . "&amp;contact_id=" .
+                    $data['CONTACT_ID'] .
+                    "&amp;arg=settranslation&amp;language=";
+                $available_locales = $data['AVAILABLE_LOCALES'];
+                $user_locale = $data['CURRENT_LOCALE_TAG'] ?? 'en_US';
+                $is_auto_translating = ($current_language === 'off' ||
+                    empty($current_language)) &&
+                    $user_locale !== 'en_US' &&
+                    $user_locale !== C\DEFAULT_LOCALE &&
+                    isset($available_locales[$user_locale]);
+                if ($current_language !== 'off' &&
+                    isset($available_locales[$current_language])) {
+                    $current_locale_name = $available_locales[$current_language];
+                    unset($available_locales[$current_language]);
+                    $reordered_locales = [$current_language =>
+                        $current_locale_name] + $available_locales;
+                    $show_top_text = $current_locale_name;
+                } elseif ($is_auto_translating) {
+                    $current_locale_name = $available_locales[$user_locale];
+                    unset($available_locales[$user_locale]);
+                    $reordered_locales = [$user_locale =>
+                        $current_locale_name] + $available_locales;
+                    $show_top_text = $current_locale_name;
+                    $current_language = $user_locale;
+                } else {
+                    $reordered_locales = $available_locales;
+                    $show_top_text = tl('toggle_helper_off');
+                }
+                $this->view->helper("options")->renderLinkDropDown(
+                    "translation-language", $reordered_locales,
+                    $current_language, $translation_url, false, "",
+                    $show_top_text);
+                }
+                ?>
             </h2>
             </div>
             <div id="av-call"></div>
             <div id="conversation" class="conversation" data-time="<?=
-                $now?>" ><?php
+                $now?>" data-contact-id="<?= $data['CONTACT_ID'] ?? '' ?>"
+                data-loaded-count="<?= count($data['MESSAGES']) ?>"
+                data-oldest-timestamp="<?= isset($data['MESSAGES']) &&
+                count($data['MESSAGES']) > 0 ?
+                $data['MESSAGES'][count($data['MESSAGES']) - 1]['PUBDATE'] :
+                $now ?>"
+                onscroll="handleConversationScroll()" ><?php
             $old_pubdate = $now;
             $old_user = $data['MESSAGES'][0]['USER_NAME'] ?? "";
             $num_messages = count($data['MESSAGES']);
             for ($i = 0; $i < $num_messages; $i++) {
                 $message_info = $data['MESSAGES'][$i];
-                $next_message_info = $data['MESSAGES'][$i + 1] ?? $message_info;
+                $next_message_info = $data['MESSAGES'][$i + 1] ??
+                    $message_info;
                 $next_user_name = $next_message_info['USER_NAME'];
                 if (empty($message_info[self::DESCRIPTION])) {
                     continue;
@@ -259,9 +303,11 @@ class UsermessagesElement extends Element implements CrawlConstants
                 $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_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_age < C\ONE_DAY &&
+                    $pub_date_diff > C\ONE_HOUR) ||
                     ($pub_date_diff > C\ONE_DAY);
                 $pub_date_string =
                     $this->view->helper("feeds")->getPubdateString(
-- 
2.39.5 (Apple Git-154)

