LZT_ConversationPlus

Show HTML username in typing notice + show who read messages (IndexedDB storage)

// ==UserScript==
// @name         LZT_ConversationPlus
// @namespace    MeloniuM/LZT
// @version      2.0
// @description  Show HTML username in typing notice + show who read messages (IndexedDB storage)
// @author       MeloniuM
// @license      MIT
// @match        https://lolz.live/conversations/*
// @grant        none
// ==/UserScript==
(function($, XenForo)
{
    'use strict';

    $('<style id="convInfoStyles">').text(`
    .conv-info-list {
      list-style: none;
      padding: 0;
      margin: 5px 0 0;
    }
    .conv-info-list li {
      display: flex;
      justify-content: space-between;
      padding: 3px 0;
      border-bottom: 1px dashed rgba(255,255,255,0.15);
    }
    .conv-info-list li:last-child {
      border-bottom: none;
    }
    .conv-info-list .name {
      color: #ddd;
    }
    .conv-info-list .status {
      min-width: 25px;
      text-align: right;
      font-weight: 600;
    }
  `).appendTo('head');

    $('<style id="convReadersStyles">').text(`
    @keyframes cnvsContextMenu { from { opacity: 0.99; } to { opacity: 1; } }

    .popup-menu.lztng-7uied4 > .menu { animation: cnvsContextMenu 0.001s; }

    .readInfo {
        font-weight: 600;
        display: flex;
        align-items: center;
        gap: 6px;
        padding: 6px 10px !important;
        background-color: var(--contentBackground);
        border: 1px solid var(--primaryDark);
        border-radius: 10px;
        -webkit-user-select: none;
        box-shadow: 0 5px 26px 0 rgb(0 0 0 / 0.32);
        margin-bottom: 5px;
        font-size: 13px;
        color: var(--contentText);
        transition: all 0.2s ease-in-out;
    }

    .readInfo:hover {
        cursor: pointer;
        background-color: var(--primaryDarker);
        transition: all 0.2s ease-in-out;
    }

    .readInfo .avatars {
        display: flex;
        align-items: center;
    }

    .readInfo .avatars img {
        width: 20px;
        height: 20px;
        border-radius: 50%;
        border: 2px solid var(--contentBackground);
        margin-left: -8px;
    }

    .readInfo .avatars img:first-child { margin-left: 0; }
    .readInfo .avatars .more {
        width: 20px;
        height: 20px;
        border-radius: 50%;
        background: #444;
        color: var(--contentText);
        font-size: 11px;
        display: flex;
        align-items: center;
        justify-content: center;
        margin-left: -8px;
    }

    .readersView {
        gap: 6px;
        padding: 0 !important;
        background-color: var(--contentBackground);
        border: 1px solid var(--primaryDark);
        border-radius: 10px;
        -webkit-user-select: none;
        box-shadow: 0 5px 26px 0 rgb(0 0 0 / 0.32);
        margin-bottom: 5px;
        font-size: 13px;
        color: var(--contentText);
    }

    .back-btn {
        width: 20px;
        height: 20px;
        background: url(data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiBzdHJva2U9InJnYigxNDAsMTQwLDE0MCkiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48bGluZSB4MT0iMTkiIHgyPSI1IiB5MT0iMTIiIHkyPSIxMiIvPjxwb2x5bGluZSB0cmFuc2Zvcm09InJvdGF0ZSgtOTAsMTIsMTIpIiBwb2ludHM9IjUgMTIgMTIgNSAxOSAxMiIvPjwvc3ZnPg==) no-repeat center;
    }

    .readersViewList {
        max-height: 250px;
        overflow-y: auto;
        padding: 4px;
        display: flex;
        flex-direction: column;
        gap: 2px;
    }

    .readersViewList .reader {
        display: flex;
        align-items: center;
        gap: 6px;
        padding: 4px 6px;
        border-radius: 6px;
        transition: all 0.2s ease-in-out;
        position: relative;
    }

    .readersViewList .reader:hover {
        background-color: var(--primaryDarker);
        cursor: pointer;
        transition: all 0.2s ease-in-out;
    }

    .readersViewList .reader:active {
        opacity: 0.72;
        transition: all 0.2s ease-in-out;
    }

    .readersViewList .reader img {
        width: 28px;
        height: 28px;
        border-radius: 50%;
    }

    .readersViewList .reader .text-block {
        display: flex;
        flex-direction: column;
        word-break: break-word;
        gap: 2px;
    }

    .readersViewList .reader .text-block .readerUsername {
        font-weight: bold;
        line-height: 20px;
    }

    .readersViewList .reader .text-block .readerUsername:hover a {
        text-decoration: none;
    }

    .readersViewList .reader .text-block .time {
        font-size: 12px;
        color: var(--mutedTextColor);
        line-height: 20px;
    }

    .readersViewHeader {
        display: flex;
        align-items: center;
        gap: 5px;
        padding: 8px 10px;
        border-bottom: 1px solid var(--primaryDarker);
        cursor: pointer;
    }
  `).appendTo('head');

    const typingUsers = {};
    let isHooked = false;

    // --- IndexedDB helpers ---
    let openDBPromise = null;
    function openDB(){
        if (!openDBPromise) {
            openDBPromise = new Promise((resolve, reject) =>{
                const req = indexedDB.open("LZTConversationPlus", 2);
                req.onupgradeneeded = (e) =>{
                    const db = e.target.result;
                    if (!db.objectStoreNames.contains("reads")){
                        const store = db.createObjectStore("reads",{
                            keyPath: "id",
                            autoIncrement: true
                        });
                        store.createIndex("byConversation", ["conversationId", "userId", "readDate"]);
                    }
                };
            req.onsuccess = () => resolve(req.result);
                req.onerror = () => reject(req.error);
            });
        }

        return openDBPromise;
    }
    async function saveRead(conversationId, userId, readDate){
        const db = await openDB();
        return new Promise((resolve, reject) => {
            const tx = db.transaction("reads", "readwrite");
            tx.objectStore("reads").add({
                conversationId,
                userId,
                readDate
            });
            tx.oncomplete = () => resolve();
            tx.onerror = () => reject(tx.error);
        });
    }
    async function getReaders(conversationId, msgDate) {
        const db = await openDB();
        return new Promise((resolve, reject) => {
            const tx = db.transaction("reads", "readonly");
            const store = tx.objectStore("reads").index("byConversation");
            const range = IDBKeyRange.bound([conversationId, 0, msgDate], [conversationId, Infinity, Infinity]);
            const req = store.getAll(range);
            req.onsuccess = () => {
                // для каждого userId берём минимальное readDate >= msgDate
                const filtered = req.result.filter(r => r.readDate >= msgDate)
                const grouped = {};
                for (const r of filtered) {
                    if ((!grouped[r.userId] || r.readDate < grouped[r.userId].readDate) && r.readDate >= msgDate) {
                        grouped[r.userId] = {
                            userId: r.userId,
                            readDate: r.readDate
                        };
                    }
                }
                // вернём [{userId, readDate}, ...]
                resolve(Object.values(grouped));
            };
            req.onerror = () => reject(req.error);
        });
    }

    async function cleanupReads() {
        const db = await openDB();
        return new Promise((resolve, reject) => {
            const tx = db.transaction("reads", "readwrite");
            const store = tx.objectStore("reads");

            const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30 дней назад

            // идём по всем записям
            const cursorReq = store.openCursor();
            cursorReq.onsuccess = (e) => {
                const cursor = e.target.result;
                if (cursor) {
                    const value = cursor.value;
                    // если дата записи старее cutoff → удаляем
                    if (value.date < cutoff) {
                        store.delete(cursor.primaryKey);
                    }
                    cursor.continue();
                }
            };

            tx.oncomplete = () => resolve();
            tx.onerror = () => reject(tx.error);
        });
    }

    const LocalStorage = {
        get(key, def = null) {
            try {
                const v = localStorage.getItem(`lztng_${key}`);
                return v === null ? def : JSON.parse(v);
            } catch {
                return def;
            }
        },
        set(key, val) {
            try {
                localStorage.setItem(`lztng_${key}`, JSON.stringify(val));
            } catch {}
        }
    };
    // --- API-клиент ---
    async function xenApiFetch(url, options = {}) {
        const apiUrl = constructApiUrl(url);
        const token = await fetchToken(options.scopes || [], options.secret_answer);
        const headers = {
            ...(options.headers || {}),
            Authorization: `Bearer ${token}`,
        };
        const finalOptions = {
            method: options.method || 'GET',
            headers,
        };
        const res = await fetch(apiUrl, finalOptions);
        return await res.json();
    }

    function constructApiUrl(url) {
        const host = document.location.host;
        if (host.split('.').length > 2) {
            const cleanUrl = url.startsWith('/') ? url.substring(1) : url;
            return `https://${host}/api/index.php?${cleanUrl.replace('?', '&')}`;
        } else {
            const cleanUrl = url.startsWith('/') ? url : `/${url}`;
            return `https://api.${host}${cleanUrl}`;
        }
    }
    async function fetchToken(scopes, secret_answer) {
        const existingToken = getTokenFromStorage(scopes);
        if (existingToken) return existingToken;
        return await getTokenFromServer(scopes, secret_answer);
    }

    function getTokenFromStorage(requiredScopes) {
        const storedTokens = LocalStorage.get('token-storage-' + XenForo.visitor.user_id, []);
        const currentTime = Date.now();
        for (const tokenData of storedTokens) {
            if (tokenData.expires <= currentTime) continue;
            if (requiredScopes.every((scope) => tokenData.scopes.includes(scope))) {
                return tokenData.token;
            }
        }
        return null;
    }

    function getTokenFromServer(scopes, secret_answer) {
        scopes = scopes || [];
        secret_answer = secret_answer || '';
        return new Promise(function(resolve, reject) {
            try {
                XenForo.ajax('/login/generate-temporary-token', {
                    scope: scopes,
                    secret_answer: secret_answer
                }, function(resp) {
                    if (!resp) return reject(new Error('Empty response from token endpoint'));
                    if (typeof XenForo.hasResponseError === 'function' && XenForo.hasResponseError(resp)) return reject(resp);
                    var tokenInfo = resp;
                    var newToken = {
                        token: tokenInfo.token,
                        expires: tokenInfo.expires * 1000,
                        scopes: scopes
                    };
                    appendToken(newToken);
                    resolve(newToken.token);
                });
            } catch (err) {
                reject(err);
            }
        });
    }

    function appendToken(newToken) {
        const storedTokens = LocalStorage.get('token-storage-' + XenForo.visitor.user_id, []);
        const currentTime = Date.now();
        const validTokens = storedTokens.filter((token) => token.expires > currentTime);
        validTokens.push(newToken);
        LocalStorage.set('token-storage-' + XenForo.visitor.user_id, validTokens);
    }

    // --- Уник в notice typing ---
    function updateTypingUsers() {
        const notice = $('.TypingNotice');
        if (!notice.length || !Object.keys(typingUsers).length) {
            notice.css('opacity', 0);
            return;
        }
        const usernames = [...new Set(Object.values(typingUsers).map(t => t.username))].slice(0, 3);
        const count = usernames.length;
        const html = count === 1 ? XenForo.phrases.user_is_typing.replace('{{user}}', usernames[0]) : XenForo.phrases.users_are_typing.replace(/\{\{user([12])}}/g, (_, id) => {
            if (count <= 2 || id === '1') return usernames[id - 1] || '';
            if (id === '2') {
                return XenForo.phrases.count_more.replace('{{count}}', count - 1);
            }
        });
        notice.find('.Content').html(html);
        notice.css('opacity', 1);
    }
    let isRenderingReaders = false;
    async function renderReaders(menu, readers = null) {
        if (!menu) return;
        const $popup = $(menu).closest('.popup-menu');

        if (!$popup.length) return;
        const $msg = $('.message.Selected');

        if ($msg.length !== 1) return;
        const convId = Im.conversationId;

        const msgDate = $msg.find(".messageDate").data("absolutetime");
        if (!convId || !msgDate) return;
        if (!readers) {
            readers = await getReaders(convId, msgDate); // [{userId, readDate}]
        }
        if (!readers.length) return;
        const isPrivate = $('.conversationRecipientUsername').length > 0; // личный диалог
        if (isPrivate) {
            // Для личного диалога — просто показываем время прочтения
            const userIdMatch = document.querySelector(".user_avatar_conversation-header-block .user_avatar")?.className.match(/Av(\d+)s/);
            if (!userIdMatch) return;
            const userId = parseInt(userIdMatch[1], 10);
            // Находим запись о прочтении именно этого юзера
            const reader = readers.find(r => r.userId === userId);
            if (!reader) return; // если юзер ещё не прочитал
            // в личном диалоге один собеседник
            const username = $('.conversationRecipientUsername').text().trim() || 'Собеседник';
            const readDate = new Date(reader.readDate * 1000).toLocaleString();
            const html = `<div style="font-size:12px;color:#ccc;">Прочитано ${username}: ${readDate}</div>`;
            let $extra = $popup.find('.readInfo');
            if ($extra.length) {
                $extra.html(html);
            } else {
                $extra = $('<div class="readInfo"></div>').html(html);
                $extra.insertBefore(menu);
            }
            return;
        }
        // --- Групповой чат ---
        const enriched = [];
        for (const r of readers) {
            const $row = $(`.ConversationRecipientsList li:has(.row-users-chat[data-user-id=${r.userId}])`);
            if (!$row.length) continue;
            const username = $(`.ConversationRecipientsList .row-users-chat[data-user-id=${r.userId}] .username`).get(0).outerHTML;
            const avatar = $row.find('.autoCompleteAvatar').attr('src');
            enriched.push({
                userId: r.userId,
                username,
                avatar,
                readDate: r.readDate
            });
        }
        if (!enriched.length) return;

        $(menu).data("readers", enriched);
        // 👥 превью (аватары + +N)
        const avatars = [];
        const max = 3;
        for (let i = 0; i < Math.min(max, enriched.length); i++) {
            avatars.push(`<img src="${enriched[i].avatar}" class="avatar" style="width:20px;height:20px;border-radius:50%;" />`);
        }
        if (enriched.length > max) {
            avatars.push(`<div class="more">+${enriched.length - max}</div>`);
        }

        const html = `<span>Прочитано:</span><div class="avatars">${avatars.join('')}</div>`;
        let $extra = $popup.find('.readInfo');
        if ($extra.length) {
            $extra.html(html);
        } else {
            $extra = $('<div class="readInfo"></div>').html(html);
            $extra.insertBefore(menu);
        }

        // создаём "экран списка"
        let $readersView = $popup.find('.readersView');
        if (!$readersView.length) {
            $readersView = $('<div class="readersView" style="display:none;"></div>');
            $popup.append($readersView);
        }
        const listHtml = `
    <div class="readersViewHeader">
        <span class="back-btn"></span>
        <span style="font-weight:600;">Прочитали сообщение</span>
    </div>
    <div class="readersViewList">
        ${enriched.map(r => `
            <div class="reader">
                <img src="${r.avatar}"/>
                <div class="text-block">
                    <span class="readerUsername">
                        ${r.username}
                    </span>
                    <span class="time">
                        ${new Date(r.readDate*1000).toLocaleString()}
                    </span>
                </div>
            </div>
        `).join('')}
    </div>
    `;
        $readersView.html(listHtml).xfActivate();
        $extra.off('click').on('click', function() {
            isRenderingReaders = true;
            $popup.find('.menu.lztng-7uied4').hide();
            $extra.hide();
            $readersView.show();
            isRenderingReaders = false;
        });
        $readersView.find('.back-btn').off('click').on('click', function() {
            isRenderingReaders = true;
            $readersView.hide();
            $extra.show();
            $popup.find('.menu.lztng-7uied4').show();
            setTimeout(() =>
            {
                isRenderingReaders = false;
            }, 50); // маленькая задержка
        });
    }
    // --- Hook socket events ---
    function tryHook() {
        const im = $('#Conversations').data('Im.Socket');
        if (!im || isHooked || typeof im.handleConversationsEvent !== 'function') return;
        const originalHandler = im.handleConversationsEvent.bind(im);
        im.handleConversationsEvent = function(event) {
            // ловим прочтение
            if (event?.data?.action === 'read' && event.data.type === 'conversation_message') {
                saveRead(event.data.conversation_id, event.data.user_id, event.data.user_read_date);
                const menu = $('.popup-menu.lztng-7uied4');
                if (menu.is(':visible')) {
                    renderReaders(menu);
                }
            }
            // ловим печатает
            if (event?.data?.action === 'typing' && !event.recovered && event.userId !== XenForo.visitor.user_id) {
                const notice = $('.TypingNotice');
                if (!notice.length) return;
                const rawName = event.username;
                const htmlName = $(`.ConversationRecipientsList .row-users-chat[data-user-id=${event.userId}] .username`)?.get(0)?.outerHTML || XenForo.htmlspecialchars(rawName);
                if (typingUsers[event.userId]) {
                    clearTimeout(typingUsers[event.userId].timeout);
                }
                typingUsers[event.userId] = {
                    username: htmlName,
                    timeout: setTimeout(() =>
                    {
                        delete typingUsers[event.userId];
                        updateTypingUsers();
                    }, 3000)
                };
                updateTypingUsers();
                return;
            }
            return originalHandler?.(event);
        };
        isHooked = true;
    }
    // --- Popup render ---
    document.addEventListener('animationstart', async function(e) {
        if (e.animationName === 'cnvsContextMenu' && !isRenderingReaders) {
            await renderReaders($(e.target));
        }
    }, true);

    /* ---------- Human labels for permissions ---------- */
    function humanPermLabel(key) {
        var map = {
            view: 'Просмотр',
            reply: 'Ответ',
            invite: 'Приглашать',
            manage_invite_links: 'Управление ссылками приглашения',
            kick: 'Кик / удаление участников',
            upload_avatar: 'Загрузка аватара',
            editOwnPost: 'Редактирование своих сообщений',
            stickyMessages: 'Закрепление сообщений',
            deleteOwnMessages: 'Удаление своих сообщений',
            edit: 'Редактирование'
        };
        return map.hasOwnProperty(key) ? map[key] : key;
    }

    function buildPermsHtml(perms) {
        perms = perms || {};
        var entries = [];
        for (var k in perms)
            if (perms.hasOwnProperty(k)) entries.push([k, perms[k]]);
        if (!entries.length) {
            return $('<p/>').text('Права не предоставлены.');
        }
        var $list = $('<ul/>').addClass('conv-info-list');
        entries.forEach(function(entry) {
            var k = entry[0],
                v = entry[1];
            var $li = $('<li/>')
                .append($('<span/>').addClass('name').text(humanPermLabel(k)))
                .append($('<span/>').addClass('status')
                    .css('color', v ? 'limegreen' : 'crimson')
                    .text(v ? '✓' : '✗')
                );
            $list.append($li);
        });
        return $list;
    }

    /* ---------- Overlay integration ---------- */
    $(document).bind('PopupMenuShow', '.membersAndActions .conversationHeader .Popup', function(e) {
        var $menu = e.$menu;
        let $context = $menu.data('XenForo.PopupMenu').$container;
        if ($menu.find('.view-conv-info').length || !$context.parent().hasClass('conversationHeaderPopupMenu')) return;
        //var $button = $('<li class="primaryContent view-conv-info"><a href="#">Информация о беседе</a></li>');
        var $button = $(`
    <a href="#" class="view-conv-info">
      <span class="Svg-Icon">
  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
    <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
    <line x1="12" y1="16" x2="12" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
    <circle cx="12" cy="8" r="1.2" fill="currentColor"/>
  </svg>
</span>
      Информация о беседе
    </a>
`);
        $menu.find('.blockLinksList').first().append($button);
        $button.click(async function(ev) {
            ev.preventDefault();
            if (!$button.data("overlay")) {
                // создаём модалку лениво при первом клике
                var $modal = $(`
          <div class="sectionMain">
            <h2 class="heading h1">Информация о беседе</h2>
            <div class="overlayContent" style="padding: 15px;">Загрузка...</div>
          </div>
        `);
                XenForo.createOverlay(null, $modal, {
                    className: "ConversationInfo-modal",
                    trigger: $button,
                    severalModals: true
                });
                // метод для обновления данных
                $button.data("overlay").refresh = function() {
                    let ttt = this.getOverlay()
                    ttt.find('.overlayContent').html('Загрузка...');
                    loadConversationInfo(ttt);
                };
            }
            // обновляем данные перед показом
            $button.data("overlay").load();
            $button.data("overlay").refresh();
        });
    });

    async function loadConversationInfo($modal) {
        try {
            var conversationId = $('.Conversation').data('conversationid') || (location.pathname.match(/conversations\/(\d+)/) || [])[1];
            if (!conversationId) throw new Error("Не удалось определить ID беседы");
            const resp = await xenApiFetch(`/conversations/${conversationId}`, {
                method: 'GET',
                scopes: ['read', 'conversate']
            });
            const conv = resp.conversation || resp; // иногда API заворачивает в .conversation
            const $block = $("<div>").addClass("conv-info-block");
            // Дата создания
            $block.append(
                $("<div>").append(
                    $("<b>").text("Создана: "),
                    $("<span>").text(conv.conversation_create_date ? new Date(conv.conversation_create_date * 1000).toLocaleString() : "-")
                )
            );
            // Создатель
            $block.append(
                $("<div>").append(
                    $("<b>").text("Создатель: "),
                    $("<span>").html(conv.creator_username_html || conv.creator_username || "-")
                )
            );
            // Кол-во сообщений
            $block.append(
                $("<div>").append(
                    $("<b>").text("Сообщений: "),
                    $("<span>").text(conv.conversation_message_count || "0")
                )
            );
            // Дата последнего апдейта
            if (conv.conversation_update_date) {
                $block.append(
                    $("<div>").append(
                        $("<b>").text("Последнее обновление: "),
                        $("<span>").text(new Date(conv.conversation_update_date * 1000).toLocaleString())
                    )
                );
            }
            // Участники
            if (conv.recipients && conv.recipients.length) {
                $block.append(
                    $("<div>").append(
                        $("<b>").text("Участников: "),
                        $("<span>").text(conv.recipients.length)
                    )
                );
            }
            // Права
            if (conv.permissions) {
                $block.append(
                    $("<div>").css("margin-top", "10px").append(
                        $("<b>").text("Права:"),
                        $("<div>").html(buildPermsHtml(conv.permissions))
                    )
                );
            }
            $modal.find(".overlayContent").empty().append($block);
        } catch (err) {
            console.error("Ошибка загрузки информации:", err);
            $modal.find('.overlayContent').html('<div class="error">Ошибка загрузки информации о беседе.</div>');
        }
    }
    // ===== Автообновление участников =====
    async function loadRecipients(conversationId) {
        try {
            const resp = await xenApiFetch(`/conversations/${conversationId}`, {
                method: 'GET',
                scopes: ['read', 'conversate']
            });
            const conv = resp.conversation || resp;
            return {
                recipients: conv.recipients || [],
                owner_id: conv.creator_user_id || 0
            };
        } catch (e) {
            console.error("Ошибка загрузки участников:", e);
            return {
                recipients: [],
                owner_id: 0
            };
        }
    }

    function updateRecipientsList($menu, recipients, owner_id) {
        const $ul = $menu.find('ul');
        if (!$ul.length) return;
        const now = Date.now();
        const MS_24H = 24 * 60 * 60 * 1000;
        const myId = XenForo.visitor.user_id;
        // карта username → данные
        const recMap = {};
        recipients.forEach(r => {
            if (!r.username) return;
            const uname = r.username.toLowerCase();
            const lastMs = r.last_activity ? r.last_activity * 1000 : null;
            const diff = lastMs ? now - lastMs : Infinity;
            recMap[uname] = {
                ...r,
                lastMs,
                diff
            };
        });
        // текущее содержимое списка (username → <li>)
        const existingLis = {};
        $ul.children('li').each(function() {
            const $li = $(this);
            const uname = $li.find('.username').text().trim().toLowerCase();
            if (uname) {
                existingLis[uname] = $li;
            }
        });
        // --- обновляем существующих + добавляем новых ---
        recipients.forEach(r => {
            const uname = r.username?.toLowerCase();
            if (!uname) return;
            const rec = recMap[uname];
            let $li = existingLis[uname];
            // если такого li ещё нет → создаём
            if (!$li) {
                $li = $('<li/>').css('cursor', 'pointer');
                const $avatar = $('<img/>')
                    .addClass('autoCompleteAvatar')
                    .attr('src', r.avatar || '')
                    .attr('alt', r.username);
                const $row = $('<div/>').addClass('row-users-chat').append(
                    $('<div/>').append(
                        $('<a/>')
                        .addClass('notranslate username')
                        .attr('href', `members/${r.user_id}/`)
                        .html(r.username_html || r.username)
                    )
                ).attr('data-user-id', r.user_id);
                // если владелец беседы и это не он сам → кнопка кика
                if (r.user_id && r.user_id !== myId && owner_id === myId) {
                    $row.append(
                        $('<a/>')
                        .addClass('far fa-minus-circle OverlayTrigger Tooltip')
                        .attr({
                            href: `conversations/${Im.conversationId}/kick?user_id=${r.user_id}`,
                            title: '',
                            'data-cachedtitle': 'Исключить пользователя'
                        })
                    );
                }
                const $status = $('<div/>').addClass('lastOnline muted');
                $li.append($avatar, $row, $status);
                $ul.append($li);
            }
            // обновляем статус
            let statusText = 'Не в сети';
            if (rec.is_online) {
                statusText = 'В сети';
            } else if (rec.lastMs) {
                if (rec.diff < 60_000) statusText = 'Только что';
                else if (rec.diff < 3_600_000) statusText = `Был(а) ${Math.floor(rec.diff / 60_000)} мин. назад`;
                else if (rec.diff < MS_24H) statusText = `Был(а) ${Math.floor(rec.diff / 3_600_000)} ч. назад`;
                else statusText = 'Был(а): ' + new Date(rec.lastMs).toLocaleString();
            }
            $li.find('.lastOnline')
                .text(statusText)
                .removeClass('mainc muted')
                .addClass(rec.is_online ? 'mainc' : 'muted');
        });
        // --- удаляем тех, кого больше нет в recipients ---
        $ul.children('li').each(function() {
            const $li = $(this);
            const uname = $li.find('.username').text().trim().toLowerCase();
            if (uname && !recMap[uname]) {
                // удаляем только тех, у кого был user_id (живые юзеры)
                if ($li.find('.username').attr('href')?.match(/members\/\d+/)) {
                    $li.remove();
                }
            }
        });
        // --- сортировка li ---
        const $lis = $ul.children('li').get();
        $lis.sort((a, b) => {
            const unameA = $(a).find('.username').text().trim().toLowerCase();
            const unameB = $(b).find('.username').text().trim().toLowerCase();
            const recA = recMap[unameA] || {};
            const recB = recMap[unameB];
            // если оба "удалённые"
            if (!recA && !recB) return 0;
            if (!recA) return 1; // удалённые всегда в конец
            if (!recB) return -1;
            const priA = recA.is_online ? 2 : (recA.lastMs && recA.diff < MS_24H ? 1 : 0);
            const priB = recB.is_online ? 2 : (recB.lastMs && recB.diff < MS_24H ? 1 : 0);
            if (priA !== priB) return priB - priA;
            return (recB.lastMs || 0) - (recA.lastMs || 0);
        });
        $ul.empty().append($lis);
        XenForo.activate($ul);
    }

    let recipientsTimer = null;
    let isLoadingRecipients = false;

    function startRecipientsUpdater() {
        if (recipientsTimer) clearInterval(recipientsTimer);
        recipientsTimer = setInterval(async () => {
            if (isLoadingRecipients) {
                return; // предыдущий запрос ещё не завершён
            }
            const $menu = $('.RecipientsPopup').data('XenForo.PopupMenu')?.$menu
            if (!$menu) return;
            const conversationId =
                $('.Conversation').data('conversationid') ||
                (location.pathname.match(/conversations\/(\d+)/) || [])[1];
            if (!conversationId) return;
            isLoadingRecipients = true;
            try {
                const {
                    recipients,
                    owner_id
                } = await loadRecipients(conversationId);
                updateRecipientsList($menu, recipients, owner_id);
            } catch (e) {
                console.error("Ошибка загрузки участников", e);
            } finally {
                isLoadingRecipients = false;
            }
        }, 30000); // 30 секунд
    }

    // init
    startRecipientsUpdater();
    $(document).ready(() => {
        tryHook();
    });
    //при переключении диалогов
    $('#Conversations').on('LoadConversation', () => {
        tryHook();
        startRecipientsUpdater();
    });
    (async () => {
        cleanupReads();
        console.log("Очистка старых read-записей завершена");
    })();
})(jQuery, XenForo);